require 'pathname'
# Cartage, a package builder.
class Cartage
VERSION = '1.0' #:nodoc:
# Plug-in commands that want to return a non-zero exit code should raise
# Cartage::QuietError.new(exitstatus).
class QuietError < StandardError
# Initialize the exception with +exitstatus+.
def initialize(exitstatus)
@exitstatus = exitstatus
end
# The exit status to be returned from this exception.
attr_reader :exitstatus
end
##
# :attr_accessor: name
#
# The name of the package to create. Defaults to #default_name.
##
# :method: default_name
#
# The default name of the package to be created, derived from the
# repository's Git URL.
##
# :attr_accessor: root_path
#
# The root path for the application.
##
# :method: default_root_path
#
# The default root path of the package, the top-level path of the Git
# repository.
##
# :attr_accessor: target
#
# The target where the final Cartage package will be created.
##
# :method: default_target
#
# The default target of the package, './tmp'.
##
# :attr_accessor: timestamp
#
# The timestamp to be applied to the final package name.
##
# :method: default_timestamp
#
# The default timestamp.
##
# :attr_accessor: without_groups
#
# The environments to exclude from a bundle install.
##
# :method: default_without_environments
#
# The default environments to exclude from a bundle install. The default is
# [ 'test', 'development' ].
# Commands that normally output data will have that output suppressed.
attr_accessor :quiet
# Commands will be run with extra information.
attr_accessor :verbose
# The environment to be used when resolving configuration options from a
# configuration file. Cartage configuration files do not usually have
# environment partitions, but if they do, use this to select the environment
# partition.
attr_accessor :environment
# The Config object. If +with_environment+ is +true+ (the default) and
# #environment has been set, only the subset of the config matching
# #environment will be returned. If +with_environment+ is +false+, the full
# configuration will be returned.
#
# If +for_plugin+ is specified, only the subset of the config for the named
# plug-in will be returned.
#
# # Assume that #environment is 'development'.
# cartage.config
# # => base_config[:development]
# cartage.config(with_environment: false)
# # => base_config
# cartage.config(for_plugin: 's3')
# # => base_config[:development][:plugins][:s3]
# cartage.config(for_plugin: 's3', with_environment: false)
# # => base_config[:plugins][:s3]
def config(with_environment: true, for_plugin: nil)
env = environment.to_sym if with_environment && environment
plug = for_plugin.to_sym if for_plugin
cfg = if env
base_config[env]
else
base_config
end
cfg = cfg.plugins[plug] if plug && cfg.plugins
cfg
end
# The configuration file to read. This should not be used by clients.
attr_writer :load_config #:nodoc:
# The base config file. This should not be used by clients.
attr_accessor :base_config #:nodoc:
def initialize #:nodoc:
@load_config = :default
end
# Create the package.
def pack
timestamp # Force the timestamp to be set now.
prepare_work_area
save_release_hashref
fetch_bundler
install_vendor_bundle
restore_modified_files
build_final_tarball
end
# Set or return the bundle cache. The bundle cache is a compressed tarball of
# vendor/bundle in the working path.
#
# If it exists, it will be extracted into vendor/bundle before
# bundle install --deployment is run, and it will be created after
# the bundle has been installed. In this way, bundle installation works
# almost the same way as Capistrano’s shared bundle concept as long as the
# path to the bundle_cache has been set to a stable location.
#
# On Semaphore CI, this should be created relative to
# $SEMAPHORE_CACHE.
#
# cartage pack --bundle-cache $SEMAPHORE_CACHE
def bundle_cache(location = nil)
if location || !defined?(@bundle_cache)
@bundle_cache = Pathname(location || tmp_path).
join('vendor-bundle.tar.bz2').expand_path
end
@bundle_cache
end
# Return the release hashref. If the optional +save_to+ parameter is
# provided, the release hashref will be written to the specified file.
def release_hashref(save_to: nil)
@release_hashref ||= %x(git rev-parse HEAD).chomp
File.open(save_to, 'w') { |f| f.write @release_hashref } if save_to
@release_hashref
end
# The repository URL.
def repo_url
unless defined? @repo_url
origin = %x(git remote show -n origin)
match = origin.match(%r{\n\s+Fetch URL: (?[^\n]+)})
@repo_url = match[:fetch]
end
@repo_url
end
# The path to the resulting package.
def final_tarball
@final_tarball ||= Pathname("#{final_name}.tar.bz2")
end
# The path to the resulting release_hashref.
def final_release_hashref
@final_release_hashref ||=
Pathname("#{final_name}-release-hashref.txt")
end
# A utility method for Cartage plug-ins to display a message only if verbose
# is on. Unless the command implemented by the plug-in is output only, this
# should be used.
def display(message)
__display(message)
end
private
def resolve_config!(*with_plugins)
return unless @load_config
@base_config = Cartage::Config.load(@load_config)
cfg = config
maybe_assign :target, cfg.target
maybe_assign :name, cfg.name
maybe_assign :root_path, cfg.root_path
maybe_assign :timestamp, cfg.timestamp
maybe_assign :without_groups, cfg.without
bundle_cache(cfg.bundle_cache) unless cfg.bundle_cache.nil? ||
cfg.bundle_cache.empty?
with_plugins.each do |name|
next unless respond_to? name
plugin = send(name)
next unless plugin
plugin.send(:resolve_config!, config(for_plugin: name))
end
end
def maybe_assign(name, value)
return if value.nil? || value.empty? ||
instance_variable_defined?(:"@#{name}")
send(:"#{name}=", value)
end
def __display(message, partial: false, verbose: verbose())
return unless verbose && !quiet
if partial
print message
else
puts message
end
end
def run(command)
display command.join(' ')
IO.popen(command + [ err: [ :child, :out ] ]) do |io|
__display(io.read(128), partial: true, verbose: true) until io.eof?
end
unless $?.success?
raise StandardError, "Error running '#{command.join(' ')}'"
end
end
def prepare_work_area
display "Preparing cartage work area..."
work_path.rmtree if work_path.exist?
work_path.mkpath
xf_status = cf_status = nil
manifest.resolve(root_path) do |file_list|
tar_cf_cmd = [
'tar', 'cf', '-', '-C', parent, '-h', '-T', file_list
].map(&:to_s)
tar_xf_cmd = [
'tar', 'xf', '-', '-C', work_path, '--strip-components=1'
].map(&:to_s)
IO.popen(tar_cf_cmd) do |cf|
IO.popen(tar_xf_cmd, 'w') do |xf|
xf.write cf.read
end
unless $?.success?
raise StandardError, "Error running #{tar_xf_cmd.join(' ')}"
end
end
unless $?.success?
raise StandardError, "Error running #{tar_cf_cmd.join(' ')}"
end
end
end
def save_release_hashref
display 'Saving release hashref...'
release_hashref save_to: work_path.join('release_hashref')
release_hashref save_to: final_release_hashref
end
def extract_bundle_cache
run %W(tar xfj #{bundle_cache} -C #{work_path}) if bundle_cache.exist?
end
def install_vendor_bundle
extract_bundle_cache
Bundler.with_clean_env do
Dir.chdir(work_path) do
run %w(bundle install --jobs=4 --deployment --clean --without) +
without_groups
end
end
create_bundle_cache
end
def restore_modified_files
%x(git status -s).split($/).map(&:split).map(&:last).each { |file|
restore_modified_file file
}
end
def fetch_bundler
Dir.chdir(work_path) do
run %w(gem fetch bundler)
end
end
def build_final_tarball
run %W(tar cfj #{final_tarball} -C #{tmp_path} #{name})
end
def work_path
@work_path ||= tmp_path.join(name)
end
def clean
[ work_path ] + final
end
def final
[ final_tarball, final_release_hashref ]
end
def parent
@parent ||= root_path.parent
end
def restore_modified_file(filename)
command = [
'git', 'show', "#{release_hashref}:#{filename}"
]
IO.popen(command) do |show|
work_path.join(filename).open('w') { |f| f.write show.read }
end
end
def tmp_path
@tmp_path ||= root_path.join('tmp')
end
def final_name
@final_name ||= tmp_path.join("#{name}-#{timestamp}")
end
class << self
private
def lazy_accessor(sym, default: nil, setter: nil, &block)
ivar = :"@#{sym}"
wsym = :"#{sym}="
dsym = :"default_#{sym}"
if default.nil? && block.nil?
raise ArgumentError, "No default provided."
end
if setter && !setter.respond_to?(:call)
raise ArgumentError, "setter must be callable"
end
setter ||= ->(v) { v }
dblk = if default.respond_to?(:call)
default
elsif default.nil?
block
else
-> { default }
end
define_method(sym) do
instance_variable_get(ivar) || send(dsym)
end
define_method(wsym) do |value|
instance_variable_set(ivar, setter.call(value || send(dsym)))
end
define_method(dsym, &dblk)
end
end
lazy_accessor :name, default: -> { File.basename(repo_url, '.git') }
lazy_accessor :root_path, setter: ->(v) { Pathname(v).expand_path },
default: -> { Pathname(%x(git rev-parse --show-cdup).chomp).expand_path }
lazy_accessor :target, setter: ->(v) { Pathname(v) },
default: -> { Pathname('tmp') }
lazy_accessor :timestamp, default: -> {
Time.now.utc.strftime("%Y%m%d%H%M%S")
}
lazy_accessor :without_groups, setter: ->(v) { Array(v) },
default: -> { %w(development test) }
lazy_accessor :base_config, default: -> { OpenStruct.new }
end
class << Cartage
# Run the Cartage command-line program.
def run(args) #:nodoc:
require_relative 'cartage/plugin'
Cartage::Plugin.load
Cartage::Plugin.decorate(Cartage)
cartage = Cartage.new
cli = CmdParse::CommandParser.new(handle_exceptions: true)
cli.main_options.program_name = 'cartage'
cli.main_options.version = Cartage::VERSION.split(/\./)
cli.main_options.banner = 'Manage releaseable packages.'
cli.global_options do |opts|
# opts.on('--[no-]quiet', 'Silence normal command output.') { |q|
# cartage.quiet = !!q
# }
opts.on('--[no-]verbose', 'Show verbose output.') { |v|
cartage.verbose = !!v
}
opts.on(
'-E', '--environment [ENVIRONMENT]', <<-desc
Set the environment to be used when necessary. If an environment name is not
provided, it will check the values of $RAILS_ENV and RACK_ENV. If neither is
set, this option is ignored.
desc
) { |e| cartage.environment = e || ENV['RAILS_ENV'] || ENV['RACK_ENV'] }
opts.on(
'-C', '--[no-]config-file load_config', <<-desc
Configure Cartage from a default configuration file or a specified
configuration file.
desc
) { |c| cartage.load_config = c }
end
cli.add_command(CmdParse::HelpCommand.new)
cli.add_command(CmdParse::VersionCommand.new)
cli.add_command(Cartage::PackCommand.new(cartage))
Cartage::Plugin.registered.each do |plugin|
if plugin.respond_to?(:commands)
Array(plugin.commands).flatten.each do |command|
registered_commands << command
end
end
end
registered_commands.uniq.each { |cmd| cli.add_command(cmd.new(cartage)) }
cli.parse
0
rescue Cartage::QuietError => qe
qe.exitstatus
rescue StandardError => e
$stderr.puts "Error:\n " + e.message
$stderr.puts e.backtrace.join("\n") if cartage.verbose
2
end
# Set options common to anything that builds a package (that is, it calls
# Cartage#pack).
def common_build_options(opts, cartage)
opts.on(
'-t', '--target PATH',
'The build package will be placed in PATH, which defaults to \'tmp\'.'
) { |t| cartage.target = t }
opts.on(
'-n', '--name NAME',
"Set the package name. Defaults to '#{cartage.default_name}'."
) { |n| cartage.name = n }
opts.on(
'-r', '--root-path PATH',
'Set the root path. Defaults to the repository root.'
) { |r| cartage.root_path = r }
opts.on(
'--timestamp TIMESTAMP',
'The timestamp used for the final package.'
) { |t| cartage.timestamp = t }
opts.on(
'--bundle-cache PATH',
'Set the bundle cache path.'
) { |b| cartage.bundle_cache(b) }
opts.on(
'--without GROUP1,GROUP2', Array,
'Set the groups to be excluded from bundle installation.',
) { |w| cartage.without_environments = w }
end
private
def registered_commands
@registered_commands ||= []
end
end
require_relative 'cartage/config'