blob: de211b318f4c298fbf74483c8db85ecead8cd221 [file] [log] [blame]
require 'uri'
require 'open3'
require 'fileutils'
require 'tmpdir'
require 'tempfile'
module ASF
#
# Provide access to files stored in Subversion, generally to local working
# copies that are updated via cronjobs.
#
# Note: svn paths passed to various #find methods are resolved relative to
# <tt>https://svn.apache.org/repos/</tt> if they are not full URIs.
#
class SVN
svn_base = ASF::Config.get(:svn_base)
if svn_base
Wunderbar.warn("Found override for svn_base: #{svn_base}")
else
svn_base = 'https://svn.apache.org/repos/'
end
@base = URI.parse(svn_base)
@mock = 'file:///var/tools/svnrep/'
@semaphore = Mutex.new
@testdata = {}
# path to <tt>repository.yml</tt> in the source.
REPOSITORY = File.expand_path('../../../../repository.yml', __FILE__)
@@repository_mtime = nil
@@repository_entries = nil
@svnHasPasswordFromStdin = nil
# a hash of local working copies of Subversion repositories. Keys are
# subversion paths; values are file paths.
def self.repos
@semaphore.synchronize do
svn = Array(ASF::Config.get(:svn))
# reload if repository changes
if File.exist?(REPOSITORY) && @@repository_mtime != File.mtime(REPOSITORY)
@repos = nil
end
# reuse previous results if already scanned
unless @repos
@@repository_mtime = File.exist?(REPOSITORY) && File.mtime(REPOSITORY)
@@repository_entries = YAML.load_file(REPOSITORY)
repo_override = ASF::Config.get(:repository)
if repo_override
svn_over = repo_override[:svn]
if svn_over
Wunderbar.warn("Found override for repository.yml[:svn]")
@@repository_entries[:svn].merge!(svn_over)
end
end
@repos = Hash[Dir[*svn].map { |name|
if Dir.exist? name
out, _ = self.getInfoItem(name, 'url')
[out, name] if out
end
}.compact]
end
@repos
end
end
# set a local directory corresponding to a path in Subversion. Useful
# as a test data override.
def self.[]=(name, path)
@testdata[name] = File.expand_path(path)
end
# find a local directory corresponding to a path in Subversion. Throws
# an exception if not found.
def self.[](name)
self.find!(name)
end
# Get the SVN repo entries corresponding to local checkouts
# Excludes depth=delete and depth=skip
# Optionally return all entries
# @params
# includeAll if should return all entries, default false
def self.repo_entries(includeAll=false)
if includeAll
self._all_repo_entries
else
self._all_repo_entries.reject {|_k, v| v['depth'] == 'skip' or v['depth'] == 'delete'}
end
end
# fetch a repository entry by name
# Excludes those that are present as aliases only
def self.repo_entry(name)
self.repo_entries[name]
end
# fetch a repository entry by name - abort if not found
def self.repo_entry!(name)
entry = self.repo_entry(name)
unless entry
raise Exception.new("Unable to find repository entry for #{name}")
end
entry
end
# get private and public repo names
# Excludes aliases
# @return [['private1', 'privrepo2', ...], ['public1', 'pubrepo2', ...]
def self.private_public
prv = []
pub = []
self.repo_entries().each do |name, entry|
if entry['url'].start_with? 'asf/'
pub << name
else
prv << name
end
end
return prv, pub
end
# fetch a repository URL by name
# Includes aliases
def self.svnurl(name)
entry = self._all_repo_entries[name] or return nil
url = entry['url']
unless url # bad entry
raise Exception.new("Unable to find url attribute for SVN entry #{name}")
end
return (@base + url).to_s
end
# fetch a repository URL by name - abort if not found
# Includes aliases
def self.svnurl!(name)
entry = self.svnurl(name)
unless entry
raise Exception.new("Unable to find url for #{name}")
end
entry
end
# Construct a repository URL by name and relative path - abort if name is not found
# Includes aliases
# assumes that the relative paths are cumulative, unlike URI.merge
# name - the nickname for the URL
# relpath - the relative path(s) to the file
def self.svnpath!(name, *relpath)
base = self.svnurl!(name)
base += '/' unless base.end_with? '/'
endpart = [relpath].join('/').sub(%r{^/+}, '').gsub(%r{/+}, '/')
return base + endpart
end
# find a local directory corresponding to a path in Subversion. Returns
# <tt>nil</tt> if not found.
# Excludes aliases
def self.find(name)
return @testdata[name] if @testdata[name]
result = repos[(@mock + name.sub('private/', '')).to_s.sub(/\/*$/, '')] ||
repos[(@base + name).to_s.sub(/\/*$/, '')] # lose trailing slash
# if name is a simple identifier (may contain '-'), try to match name in repository.yml
if not result and name =~ /^[\w-]+$/
entry = repo_entry(name)
result = find((@base + entry['url']).to_s) if entry
end
# recursively try parent directory
if not result and name.include? '/'
base = File.basename(name)
parent = find(File.dirname(name))
if parent and File.exist?(File.join(parent, base))
result = File.join(parent, base)
end
end
result
end
# find a local directory corresponding to a path in Subversion. Throws
# an exception if not found.
def self.find!(name)
result = self.find(name)
unless result
entry = repo_entry(name)
if entry
raise Exception.new("Unable to find svn checkout for " +
"#{@base + entry['url']} (#{name})")
else
raise Exception.new("Unable to find svn checkout for #{name}")
end
end
result
end
# retrieve info, [err] for a path in svn
# output looks like:
# Path: /srv/svn/steve
# Working Copy Root Path: /srv/svn/steve
# URL: https://svn.apache.org/repos/asf/steve/trunk
# Relative URL: ^/steve/trunk
# Repository Root: https://svn.apache.org/repos/asf
# Repository UUID: 13f79535-47bb-0310-9956-ffa450edef68
# Revision: 1870481
# Node Kind: directory
# Schedule: normal
# Depth: empty
# Last Changed Author: somebody
# Last Changed Rev: 1862550
# Last Changed Date: 2019-07-04 13:21:36 +0100 (Thu, 04 Jul 2019)
#
def self.getInfo(path, user=nil, password=nil)
return self.svn('info', path, {user: user, password: password})
end
# svn info details as a Hash
# @return hash or [nil, error message]
# Sample:
# {
# "Path"=>"/srv/svn/steve",
# "Working Copy Root Path"=>"/srv/svn/steve",
# "URL"=>"https://svn.apache.org/repos/asf/steve/trunk",
# "Relative URL"=>"^/steve/trunk",
# "Repository Root"=>"https://svn.apache.org/repos/asf",
# "Repository UUID"=>"13f79535-47bb-0310-9956-ffa450edef68",
# "Revision"=>"1870481",
# "Node Kind"=>"directory",
# "Schedule"=>"normal",
# "Depth"=>"empty",
# "Last Changed Author"=>"somebody",
# "Last Changed Rev"=>"1862550",
# "Last Changed Date"=>"2019-07-04 13:21:36 +0100 (Thu, 04 Jul 2019)"
# }
def self.getInfoAsHash(path, user=nil, password=nil)
out, err = getInfo(path, user, password)
if out
Hash[(out.scan(%r{([^:]+): (.+)[\r\n]+}))]
else
return out, err
end
end
# retrieve a single info item, [err] for a path in svn
# requires SVN 1.9+
# item must be one of the following:
# 'kind' node kind of TARGET
# 'url' URL of TARGET in the repository
# 'relative-url'
# repository-relative URL of TARGET
# 'repos-root-url'
# root URL of repository
# 'repos-uuid' UUID of repository
# 'revision' specified or implied revision
# 'last-changed-revision'
# last change of TARGET at or before
# 'revision'
# 'last-changed-date'
# date of 'last-changed-revision'
# 'last-changed-author'
# author of 'last-changed-revision'
# 'wc-root' root of TARGET's working copy
# Note: Path, Schedule and Depth are not currently supported
#
def self.getInfoItem(path, item, user=nil, password=nil)
out, err = self.svn('info', path, {item: item,
user: user, password: password})
if out
if item.end_with? 'revision' # svn version 1.9.3 appends trailing spaces to *revision items
return out.chomp.rstrip
else
return out.chomp
end
else
return nil, err
end
end
# retrieve list, [err] for a path in svn
def self.list(path, user=nil, password=nil, timestamp=false)
if timestamp
return self.svn(['list', '--xml'], path, {user: user, password: password})
else
return self.svn('list', path, {user: user, password: password})
end
end
# These keys are common to svn_ and svn
VALID_KEYS = %i[user password verbose env dryrun msg depth quiet item revision xml]
# common routine to build SVN command line
# returns [cmd, stdin] where stdin is the data for stdin (if any)
def self._svn_build_cmd(command, path, options)
bad_keys = options.keys - VALID_KEYS
if bad_keys.size > 0
raise ArgumentError.new "Following options not recognised: #{bad_keys.inspect}"
end
if command.is_a? String
# TODO convert to ArgumentError after further testing
Wunderbar.error "command #{command.inspect} is invalid" unless command =~ %r{^[a-z]+$}
else
if command.is_a? Array
command.each do |cmd|
raise ArgumentError.new "command #{cmd.inspect} must be a String" unless cmd.is_a? String
end
Wunderbar.error "command #{command.first.inspect} is invalid" unless command.first =~ %r{^[a-z]+$}
command.drop(1).each do |cmd|
# Allow --option, -lnumber or -x
Wunderbar.error "Invalid option #{cmd.inspect}" unless cmd =~ %r{^(--[a-z][a-z=]+|-l\d+|-[a-z])$}
end
else
raise ArgumentError.new "command must be a String or an Array of Strings"
end
end
# build svn command
cmd = ['svn', *command, '--non-interactive']
stdin = nil # for use with -password-from-stdin
msg = options[:msg]
cmd += ['--message', msg] if msg
depth = options[:depth]
cmd += ['--depth', depth] if depth
cmd << '--quiet' if options[:quiet]
cmd << '--xml' if options[:xml]
item = options[:item]
cmd += ['--show-item', item] if item
revision = options[:revision]
cmd += ['--revision', revision] if revision
# add credentials if required
env = options[:env]
if env
password = env.password
user = env.user
else
password = options[:password]
user = options[:user]
end
unless options[:dryrun] # don't add auth for dryrun
if password or user == 'whimsysvn' # whimsysvn user does not require password
cmd << ['--username', user, '--no-auth-cache']
end
# password was supplied, add credentials
if password
if self.passwordStdinOK?()
stdin = password
cmd << ['--password-from-stdin']
else
cmd << ['--password', password]
end
end
end
cmd << '--' # ensure paths cannot be mistaken for options
if path.is_a? Array
cmd += path
else
cmd << path
end
return cmd, stdin
end
# low level SVN command
# params:
# command - info, list etc
# Can be array, e.g. ['list', '--xml']
# path - the path(s) to be used - String or Array of Strings
# options - hash of:
# :msg - ['--message', value]
# :depth - ['--depth', value]
# :env - environment: source for user and password
# :user, :password - used if env is not present
# :quiet - if true, apply the --quiet option
# :item - [--show-item, value]
# :revision - [--revision, value]
# :verbose - show command on stdout
# :dryrun - return command array as [cmd] without executing it (excludes auth)
# :chdir - change directory for system call
# Returns:
# - stdout
# - nil, err
# - [cmd] if :dryrun
# May raise ArgumentError
def self.svn(command, path, options = {})
raise ArgumentError.new 'command must not be nil' unless command
raise ArgumentError.new 'path must not be nil' unless path
# Deal with svn-only opts
chdir = options.delete(:chdir)
open_opts = {}
open_opts[:chdir] = chdir if chdir
cmd, stdin = self._svn_build_cmd(command, path, options)
cmd.flatten!
open_opts[:stdin_data] = stdin if stdin
p cmd if options[:verbose]
return [cmd] if options[:dryrun]
# issue svn command
out, err, status = Open3.capture3(*cmd, open_opts)
# Note: svn status exits with status 0 even if the target directory is missing or not a checkout
if status.success?
if out == '' and err != '' and %w(status stat st).include? command
return nil, err
else
return out
end
else
return nil, err
end
end
# as for self.svn, but failure raises an error
def self.svn!(command, path, options = {})
out, err = self.svn(command, path, options = options)
raise Exception.new("SVN command failed: #{err}") if out.nil?
return out, err
end
DELIM = '------------------------------------------------------------------------'
# parse commit log (non-xml)
# Return:
# Array of hash entries with keys: :revision, :author, :date, :msg
# The :msg entry will be missing if the quiet log option was used
# Note: parsing XML output proved somewhat slower
def self._parse_commits(src)
out = []
state = 0
linect = ent = msg = nil # ensure visibility
src.split(%r{\R}).each do |l|
case state
when 0 # start of block, should be delim
if l == DELIM
state = 1
ent = {}
else
raise ArgumentError.new "Unexpected line: '#{l}'"
end
when 1 # header line
revision, author, date, lines = l.split(' | ')
ent = {revision: revision, author: author, date: date}
if lines =~ %r{^(\d+) lines?} # There are some log lines
linect = $1.to_i + 3 # Allow for delim, header and blank line
msg = [] # collect the log message lines here
state += 1 # get ready to collect log lines
else # no log lines provided, we are done
out << ent
state = 0
end
else # collecting log lines
state += 1
msg << l if state > 3 # skip the blank line
if state == linect # we have read all the lines
ent[:msg] = msg.join("\n")
out << ent
state = 0
end
end
end
out
end
# get list of commits from initial to current, and parses the output
# Returns: [out, err], where:
# out = array of entries, each of which is a hash
# err = error message (in which case out is nil)
def self.svn_commits(path, before, after, options = {})
out, err = ASF::SVN.svn('log', path, options.merge({revision: "#{before}:#{after}"}))
out = _parse_commits(out) if out
return out, err
end
# as for self.svn_commits, but failure raises an error
def self.svn_commits!(path, before, after, options = {})
out, err = self.svn_commits(path, before, after, options = options)
raise Exception.new("SVN command failed: #{err}") if out.nil?
return out, err
end
# low level SVN command for use in Wunderbar context (_json, _text etc)
# params:
# command - info, list etc
# Can be array, e.g. ['list', '--xml']
# path - the path(s) to be used - String or Array of Strings
# _ - wunderbar context
# options - hash of:
# :msg - ['--message', value]
# :depth - ['--depth', value]
# :quiet - if true, apply the --quiet option
# :item - [--show-item, value]
# :revision - [--revision, value]
# :auth - authentication (as [['--username', etc]])
# :env - environment: source for user and password
# :user, :password - used if env is not present
# :verbose - show command (including credentials) before executing it
# :dryrun - show command (excluding credentials), without executing it
# :sysopts - options for BuilderClass#system, e.g. :stdin, :echo, :hilite
# - options for JsonBuilder#system, e.g. :transcript, :prefix
#
# Returns:
# - status code
# May raise ArgumentError
def self.svn_(command, path, _, options = {})
raise ArgumentError.new 'command must not be nil' unless command
raise ArgumentError.new 'path must not be nil' unless path
raise ArgumentError.new 'wunderbar (_) must not be nil' unless _
# Pick off the options specific to svn_ rather than svn
sysopts = options.delete(:sysopts) || {}
auth = options.delete(:auth)
if auth
# override any other auth
%i[env user password].each do |k|
options.delete(k)
end
# convert auth for use by _svn_build_cmd
auth.flatten.each_slice(2) do |a, b|
options[:user] = b if a == "--username"
options[:password] = b if a == "--password"
end
end
cmd, stdin = self._svn_build_cmd(command, path, options)
sysopts[:stdin] = stdin if stdin
# This ensures the output is captured in the response
_.system ['echo', [cmd, sysopts].inspect] if options[:verbose] # includes auth
if options[:dryrun] # excludes auth
return _.system cmd.insert(0, 'echo')
end
# N.B. Version 1.3.3 requires separate hashes for JsonBuilder and BuilderClass,
# see https://github.com/rubys/wunderbar/issues/11
if _.instance_of?(Wunderbar::JsonBuilder) or _.instance_of?(Wunderbar::TextBuilder)
_.system cmd, sysopts, sysopts # needs two hashes
else
_.system cmd, sysopts
end
end
# As for self.svn_, but failures cause a RuntimeError
def self.svn_!(command, path, _, options = {})
rc = self.svn_(command, path, _, options = options)
raise RuntimeError.new("exit code: #{rc}\n#{_.target!}") if rc != 0
rc
end
# retrieve revision, [err] for a path in svn
def self.getRevision(path, user=nil, password=nil)
out, err = getInfo(path, user, password)
if out
# extract revision number
return out[/^Revision: (\d+)/, 1]
else
return out, err
end
end
# retrieve revision, content for a file in svn
# N.B. There is a window between fetching the revision and getting the file contents
def self.get(path, user=nil, password=nil)
revision, _ = self.getInfoItem(path, 'revision', {user: user, password: password})
if revision
content, _ = self.svn('cat', path, {user: user, password: password})
else
revision = '0'
content = nil
end
return revision, content
end
# Updates a working copy, and returns revision number
#
# Note: working copies updated out via cron jobs can only be accessed
# read only by processes that run under the Apache web server.
def self.updateSimple(path)
stdout, _ = self.svn('update', path)
revision = 0
if stdout
# extract revision number
revision = stdout[/^At revision (\d+)/, 1]
end
revision
end
# Specialised code for updating CI
# Updates cache if SVN commit succeeds
# user and password are required because the default URL is private
def self.updateCI(msg, env, options={})
# Allow override for testing
ciURL = options[:url] || self.svnurl('board')
Dir.mktmpdir do |tmpdir|
# use dup to make testing easier
user = env.user.dup
pass = env.password.dup
# checkout committers/board (this does not have many files currently)
out, err = self.svn('checkout', [ciURL, tmpdir],
{quiet: true, depth: 'files',
user: user, password: pass})
raise Exception.new("Checkout of board folder failed: #{err}") unless out
# read in committee-info.txt
file = File.join(tmpdir, 'committee-info.txt')
info = File.read(file)
info = yield info # get the updates the contents
# write updated file to disk
File.write(file, info)
# commit the updated file
out, err = self.svn('commit', [file, tmpdir],
{quiet: true, msg: msg,
user: user, password: pass})
raise Exception.new("Update of committee-info.txt failed: #{err}") unless out
end
end
# update a file or directory in SVN, working entirely in a temporary
# directory
# Intended for use from GUI code
# Must be used with a block, which is passed the temporary directory name
# and the current file contents (may be empty string)
# The block must return the updated file contents
#
# Parameters:
# path - the path to be used, directory or single file
# msg - commit message
# env - environment (queried for user and password)
# _ - wunderbar context
# options - hash of:
# :dryrun - show command (excluding credentials), without executing it
# :diff - show diff before committing
def self.update(path, msg, env, _, options={})
if File.directory? path
dir = path
basename = nil
else
dir = File.dirname(path)
basename = File.basename(path)
end
rc = 0
Dir.mktmpdir do |tmpdir|
# create an empty checkout
self.svn_('checkout', [self.getInfoItem(dir, 'url'), tmpdir], _,
{depth: 'empty', env: env})
# retrieve the file to be updated (may not exist)
if basename
tmpfile = File.join(tmpdir, basename)
self.svn_('update', tmpfile, _, {env: env})
else
tmpfile = nil
end
# determine the new contents
if not tmpfile
# updating a directory
previous_contents = contents = nil
yield tmpdir, ''
elsif File.file? tmpfile
# updating an existing file
previous_contents = File.read(tmpfile)
contents = yield tmpdir, File.read(tmpfile)
else
# updating a new file
previous_contents = nil
contents = yield tmpdir, ''
previous_contents = File.read(tmpfile) if File.file? tmpfile
end
# create/update the temporary copy
if contents and not contents.empty?
File.write tmpfile, contents
unless previous_contents
self.svn_('add', tmpfile, _, {env: env}) # TODO is auth needed here?
end
elsif tmpfile and File.file? tmpfile
File.unlink tmpfile
self.svn_('delete', tmpfile, _, {env: env}) # TODO is auth needed here?
end
if options[:dryrun]
# show what would have been committed
rc = self.svn_('diff', tmpfile || tmpdir, _)
return rc # No point checking for pending changes
end
self.svn_('diff', tmpfile || tmpdir, _) if options[:diff]
# commit the changes
rc = self.svn_('commit', tmpfile || tmpdir, _,
{msg: msg, env: env})
# fail if there are pending changes
out, _err = self.svn('status', tmpfile || tmpdir) # Need to use svn rather than svn_ here
unless rc == 0 && out && out.empty?
raise "svn failure #{rc} #{path.inspect} #{out}"
end
end
rc # return last status
end
# DRAFT DRAFT DRAFT
# Low-level interface to svnmucc, intended for use with wunderbar
# Parameters:
# commands - array of commands
# msg - commit message
# env - environment (username/password)
# _ - Wunderbar context
# revision - the --revision svnmucc parameter (unless nil)
# options - hash:
# :tmpdir - use this temporary directory (and don't remove it)
# :verbose - if true, show command details
# :dryrun - if true, don't execute command, but show it instead
# :root - interpret all action URLs relative to the specified root
# The commands must themselves be arrays to ensure correct processing of white-space
# For example:
# commands = []
# url1 = 'https://svn.../' # etc
# commands << ['mv', url1, url2]
# commands << ['rm', url3]
# ASF::SVN.svnmucc_(commands, message, env, _, revision)
def self.svnmucc_(commands, msg, env, _, revision, options={})
raise ArgumentError.new 'commands must be an array' unless commands.is_a? Array
raise ArgumentError.new 'msg must not be nil' unless msg
raise ArgumentError.new 'env must not be nil' unless env
raise ArgumentError.new '_ must not be nil' unless _
bad_keys = options.keys - %i[dryrun verbose tmpdir root]
if bad_keys.size > 0
raise ArgumentError.new "Following options not recognised: #{bad_keys.inspect}"
end
temp = options[:tmpdir]
tmpdir = temp ? temp : Dir.mktmpdir
rc = -1 # in case
begin
cmdfile = Tempfile.new('svnmucc_input', tmpdir)
# add the commands
commands.each do |cmd|
raise ArgumentError.new 'command entries must be an array' unless cmd.is_a? Array
cmd.each do |arg|
cmdfile.puts(arg)
end
cmdfile.puts('')
end
cmdfile.rewind
cmdfile.close
syscmd = ['svnmucc',
'--non-interactive',
'--extra-args', cmdfile.path,
'--message', msg,
'--no-auth-cache',
]
if revision
syscmd << '--revision'
syscmd << revision
end
root = options[:root]
if root
syscmd << '--root-url'
syscmd << root
end
sysopts = {}
if env
if self.passwordStdinOK?()
syscmd << ['--username', env.user, '--password-from-stdin']
sysopts[:stdin] = env.password
else
syscmd << ['--username', env.user, '--password', env.password]
end
end
if options[:verbose]
_.system 'echo', [syscmd.flatten, sysopts.to_s]
end
if options[:dryrun]
rc = _.system syscmd.insert(0, 'echo')
else
if _.instance_of?(Wunderbar::JsonBuilder) or _.instance_of?(Wunderbar::TextBuilder)
rc = _.system syscmd, sysopts, sysopts # needs two hashes
else
rc = _.system syscmd, sysopts
end
end
ensure
File.delete cmdfile.path # always drop the command file
FileUtils.rm_rf tmpdir unless temp
end
rc
end
# DRAFT
# Check if an svn path exists (at the specified revision)
# Parameters:
# path - the svn uri (http, svn or file)
# env - user/pass
# options - passed to ASF::SVN.svn('list')
#
# Returns:
# true if the file exists
# false if the file does not exist
# IOError on unexpected error
def self.exist?(path, revision, env, options={})
out, err = self.svn('list', path, options.merge({env: env, revision: revision}))
return true if out && (not err)
# TODO link to where these codes are documented
if err =~ %r{^svn: warning: W160013: .*(not found|non-existent)}
return false
end
throw IOError.new("Could not check if #{path} exists: #{err}")
end
# Should agree with modules/whimsy_server/files/subversion-config-www in Puppet
MIMETYPES = { # if the extension matches, then apply the mime-type as below
'.jpg' => 'image/jpeg',
'.pdf' => 'application/pdf',
'.png' => 'image/png',
'.tif' => 'image/tiff',
'.tiff' => 'image/tiff',
}
# create a new file and fail if it already exists
# sets the mimetype if the extension is present in the MIMETYPES hash
# Parameters:
# directory - parent directory as an SVN URL
# filename - name of file to create
# data - content of file: can be a text string, or a Tempfile
# msg - commit message
# env - user/pass
# _ - wunderbar context
# options:
# dryrun: passed to svnmucc_
#
# Returns:
# 0 on success
# 1 if the file exists
# IOError on unexpected error
def self.create_(directory, filename, data, msg, env, _, options={})
parentrev, err = self.getInfoItem(directory, 'revision', env.user, env.password)
unless parentrev
throw RuntimeError.new("Failed to get revision for #{directory}: #{err}")
end
target = File.join(directory, filename)
return 1 if self.exist?(target, parentrev, env) # options not relevant here
rc = nil
Dir.mktmpdir do |tmpdir|
if data.instance_of? Tempfile
source = data
else
source = Tempfile.new('create_source', tmpdir)
File.write(source, data, encoding: Encoding::BINARY)
end
commands = [['put', source.path, target]]
# Add mimetype if known
mimetype = MIMETYPES[File.extname(filename)]
if mimetype
commands << ['propset', 'svn:mime-type', mimetype, target]
end
# Detect file created in parallel. This generates the error message:
# svnmucc: E160020: File already exists: <snip> path 'xxx'
rc = self.svnmucc_(commands, msg, env, _, parentrev, options.merge({tmpdir: tmpdir}))
unless rc == 0
error = _.target?['transcript'][1] rescue ''
unless error =~ %r{^svnmucc: E160020: File already exists:}
throw RuntimeError.new("Unexpected error creating file: #{error}")
end
end
end
rc
end
# DRAFT DRAFT DRAFT
# checkout file and update it using svnmucc put
# the block can return additional info, which is used
# to generate extra commands to pass to svnmucc
# which are included in the same commit
# The extra parameter is an array of commands
# These must themselves be arrays to ensure correct processing of white-space
# Parameters:
# path - file path or SVN URL (http(s): or file: or svn:)
# message - commit message
# env - for username and password
# _ - Wunderbar context
# options:
# :dryrun - don't do the update
# :verbose - show what will be done
# :tmpdir - use this temporary directory (and don't remove it)
# For example:
# ASF::SVN.multiUpdate_(path, message, env, _) do |text|
# out = '...'
# extra = []
# url1 = 'https://svn.../' # etc
# extra << ['mv', url1, url2]
# extra << ['rm', url3]
# [out, extra]
# end
def self.multiUpdate_(path, msg, env, _, options = {})
tmpdir = options[:tmpdir] || Dir.mktmpdir
if File.file? path
basename = File.basename(path)
parentdir = File.dirname(path)
parenturl = ASF::SVN.getInfoItem(parentdir, 'url')
else
uri = URI.parse(path)
# allow file: and svn URIs for local testing
if %w(http https file svn).include? uri.scheme
basename = File.basename(uri.path)
parentdir = File.dirname(uri.path)
uri.path = parentdir
parenturl = uri.to_s
else
raise ArgumentError.new("Path '#{path}' must be a file or URL")
end
end
outputfile = File.join(tmpdir, basename)
begin
# create an empty checkout
rc = self.svn_('checkout', [parenturl, tmpdir], _, {depth: 'empty', env: env})
raise "svn failure #{rc} checkout #{parenturl}" unless rc == 0
# checkout the file
rc = self.svn_('update', outputfile, _, {env: env})
raise "svn failure #{rc} update #{outputfile}" unless rc == 0
# N.B. the revision is required for the svnmucc put to prevent overriding a previous update
# this is why the file is checked out rather than just extracted
filerev = ASF::SVN.getInfoItem(outputfile, 'revision', env.user, env.password) # is auth needed here?
fileurl = ASF::SVN.getInfoItem(outputfile, 'url', env.user, env.password)
# get the new file contents and any extra svn commands
contents, extra = yield File.read(outputfile)
# update the file
File.write outputfile, contents
# build the svnmucc commands
cmds = []
cmds << ['put', outputfile, fileurl]
extra.each do |cmd|
cmds << cmd
end
# Now commit everything
if options[:dryrun]
puts cmds # TODO: not sure this is correct for Wunderbar
else
rc = ASF::SVN.svnmucc_(cmds, msg, env, _, filerev, {tmpdir: tmpdir, verbose: options[:verbose]})
raise "svnmucc failure #{rc} committing" unless rc == 0
rc
end
ensure
FileUtils.rm_rf tmpdir unless options[:tmpdir]
end
end
EPOCH_SEP = ':' # separator
EPOCH_TAG = 'epoch' + EPOCH_SEP # marker in file to show epochs are present
EPOCH_LEN = EPOCH_TAG.size
# update directory listing in /srv/svn/<name>.txt
# N.B. The listing includes the trailing '/' so directory names can be distinguished
# @return filerev, svnrev
# on error return nil, message
def self.updatelisting(name, user=nil, password=nil, storedates=false, dir = nil)
url = self.svnurl(name)
unless url
return nil, "Cannot find URL for '#{name}'"
end
listfile, listfiletmp = self.listingNames(name, dir)
filerev = "0"
svnrev = "?"
filedates = false
begin
open(listfile) do |l|
filerev = l.gets.chomp
if filerev.start_with? EPOCH_TAG # drop the marker
filerev = filerev[EPOCH_LEN..-1]
filedates = true
end
end
rescue
end
svnrev, err = self.getInfoItem(url, 'last-changed-revision', user, password)
if svnrev
begin
unless filerev == svnrev && filedates == storedates
list = self.list(url, user, password, storedates)
if storedates
require 'nokogiri'
require 'time'
open(listfiletmp, 'w') do |w|
w.puts "#{EPOCH_TAG}#{svnrev}" # show that this file has epochs
xml_doc = Nokogiri::XML(list)
xml_doc.css('entry').each do |entry|
kind = entry.css('@kind').text
name = entry.at_css('name').text
date = entry.at_css('date').text
epoch = Time.parse(date).strftime('%s')
# The separator is the last character of the epoch tag
w.puts "%s#{EPOCH_SEP}%s%s" % [epoch, name, kind == 'dir' ? '/' : '']
end
end
else
open(listfiletmp, 'w') do |w|
w.puts svnrev
w.puts list
end
end
File.rename(listfiletmp, listfile)
end
rescue Exception => e
return nil, e.inspect
end
else
return nil, err
end
return filerev, svnrev
end
# get listing if it has changed
# @param
# - name: alias for SVN checkout
# - tag: previous tag to check for changes, default nil
# - trimSlash: whether to trim trailing '/', default true
# - getEpoch: whether to return the epoch if present, default false
# @return tag, Array of names
# or tag, nil if unchanged
# or Exception if error
# The tag should be regarded as opaque
def self.getlisting(name, tag=nil, trimSlash = true, getEpoch = false, dir = nil)
listfile, _ = self.listingNames(name, dir)
curtag = "%s:%s:%d" % [trimSlash, getEpoch, File.mtime(listfile)]
if curtag == tag
return curtag, nil
else
open(listfile) do |l|
# fetch the file revision from the first line
filerev = l.gets.chomp # TODO should we be checking filerev?
if filerev.start_with?(EPOCH_TAG)
if getEpoch
trimEpoch = -> x { x.split(EPOCH_SEP, 2) } # return as array
else
trimEpoch = -> x { x.split(EPOCH_SEP, 2)[1] } # strip the epoch
end
else
trimEpoch = nil
end
if trimSlash
list = l.readlines.map {|x| x.chomp.chomp('/')}
else
list = l.readlines.map(&:chomp)
end
list = list.map(&trimEpoch) if trimEpoch
return curtag, list
end
end
end
# Does this host's installation of SVN support --password-from-stdin?
def self.passwordStdinOK?
return @svnHasPasswordFromStdin unless @svnHasPasswordFromStdin.nil?
out, _err, status = Open3.capture3('svn', 'help', 'cat', '-v')
if status.success? && out
@svnHasPasswordFromStdin = out.include? '--password-from-stdin'
else
@svnHasPasswordFromStdin = false
end
@svnHasPasswordFromStdin
end
private
# Calculate svn parent directory allowing for overrides
def self.svn_parent
svn = ASF::Config.get(:svn)
if svn.instance_of? String and svn.end_with? '/*'
File.dirname(svn)
else
File.join(ASF::Config.root, 'svn')
end
end
# get listing names for updating and returning SVN directory listings
# Returns:
# [listing-name, temporary name]
def self.listingNames(name, dir = nil)
if dir
throw IOError.new("Invalid directory #{dir}") unless Dir.exist? dir
else
dir = self.svn_parent
end
return File.join(dir, "%s.txt" % name),
File.join(dir, "%s.tmp" % name)
end
# Get all the SVN entries
# Includes those that are present as aliases only
# Not intended for external use
def self._all_repo_entries
self.repos # refresh @@repository_entries
@@repository_entries[:svn]
end
end
end