Last active
August 29, 2015 14:01
-
-
Save smashedlife/ed4436568137bc153abe to your computer and use it in GitHub Desktop.
git-deploy
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby | |
if ARGV[0].nil? || ARGV[0].empty? | |
deploy = 'deploy.yml' | |
else | |
deploy = ARGV[0] + '.yml' | |
end | |
unless File.exists? deploy | |
puts "File does not exist: " + deploy | |
exit | |
end | |
require 'rubygems' | |
require 'timeout' | |
require 'yaml' | |
require 'uri' | |
require 'net/ftp' | |
require 'net/ssh' | |
require 'net/sftp' | |
require 'tempfile' | |
require 'socket' | |
STDOUT.sync = true | |
# Ignored files | |
$ignore_file_list = ["deploy.yml", ".gitignore"] | |
# Store the current revision | |
revision = `git rev-parse HEAD`.chomp.strip | |
revision_file = Tempfile.new revision | |
revision_file.write revision | |
revision_file.close | |
service_failures = {} | |
services = YAML.load_file deploy | |
settings = services.delete('settings') || {} | |
trap 'INT' do | |
raise Interrupt, nil | |
end | |
module Git | |
class << self | |
def diff_uncommitted(local_path) | |
command = "git diff --name-status; git diff --cached --name-status" | |
changed_files(command) | |
end | |
# Figures out only which files need updating | |
# so don't upload duplicate files | |
def diff_committed(local_path, revision = nil) | |
remote_files = {} | |
if revision | |
command = "git diff --name-status #{revision} -- #{local_path}" | |
changed_files(command) | |
else | |
remote_files = {} | |
`git ls-files`.split("\n").each {|file| remote_files[file] = 'M'} | |
remote_files | |
end | |
end | |
def changed_files(command, init = nil) | |
files = `#{command}` | |
puts command | |
case $?.exitstatus | |
when 0, 141 | |
# pass | |
else | |
return false | |
end | |
remote_files = {} | |
files.split("\n").reverse.each do |line| | |
c = line[0..0] | |
c = 'M' if c == 'A' | |
c = 'M' if c == 'C' | |
if init | |
c = 'M' if c == 'H' | |
end | |
next unless c == 'M' || c == 'D' | |
file = line[2..-1] | |
if not $ignore_file_list.include? file | |
if remote_files.key? file | |
if remote_files[file] == 'M' && c == 'D' | |
remote_files[file] = 'D' | |
elsif remote_files[file] == 'D' && c == 'M' | |
remote_files[file] = 'M' | |
end | |
else | |
remote_files[file] = c | |
end | |
end | |
end | |
remote_files | |
end | |
end | |
class Service | |
attr_reader :failures | |
def initialize(uri, options) | |
@uri = uri | |
@host = options['host'] | |
@options = options | |
@scheme = options['scheme'] | |
@failures = [] | |
@max_retries = options['retries'] || 1 | |
@max_retries = 1 if @max_retries <= 0 | |
end | |
def automate | |
upload_maintenance_file = @options['maintenance_file'] && @options['maintenance_deploy_to'] | |
tries = 1 | |
until tries > @max_retries | |
self.connect | |
begin | |
if ! self.diff | |
self.disconnect | |
return | |
end | |
if upload_maintenance_file | |
puts "Uploading Maintenance file" | |
self.upload_maintenance_file | |
end | |
self.upload | |
self.upload_revision_file | |
if upload_maintenance_file | |
puts "Removing Maintenance file" | |
self.remove_maintenance_file | |
end | |
self.disconnect | |
return true | |
rescue Interrupt, Errno::ETIMEDOUT, Errno::ECONNRESET, Timeout::Error => e | |
tries += 1 | |
@failures << e | |
puts "Interrupting...going to retry" | |
if e.is_a? Interrupt | |
begin | |
Timeout::timeout(5) do | |
self.disconnect | |
end | |
rescue | |
end | |
end | |
if tries == @max_tries | |
puts "Not retrying (#{tries} out of #{@max_retries})" | |
else | |
puts "Retrying (#{tries} out of #{@max_retries})" | |
end | |
rescue Net::FTPTempError, Net::FTPPermError => e | |
@failures << e | |
return false | |
end | |
end | |
tries != @max_retries | |
end | |
def diff | |
if @remote_files.is_a? Hash | |
return @remote_files | |
end | |
# Decide upload strategy | |
if ARGV[0] == 'diff' || ARGV[0] == 'd' | |
@remote_files = Git.diff_uncommitted(@options['local_path']) | |
else | |
@remote_files = self.diff_committed | |
end | |
end | |
def diff_committed | |
remote_revision = self.get_remote_revision | |
if !remote_revision | |
if !dir_empty? | |
puts 'Missing remote revision in non empty directory' | |
exit | |
else | |
remote_files = Git.diff_committed(@options['local_path']) | |
end | |
elsif @options['revision'] == remote_revision | |
if !@options['overwrite_if_same_revision'] | |
puts "Same revision, ignoring" | |
return false | |
end | |
puts "Same revision, overwriting" | |
remote_files = Git.diff_committed(@options['local_path']) | |
else | |
remote_files = Git.diff_committed(@options['local_path'], "#{remote_revision}..HEAD") | |
end | |
end | |
def upload | |
length = @options['length'] | |
@remote_files.each do |local_file, modifier| | |
if length != 0 | |
if local_file[0...length] != @options['local_path'] | |
next | |
else | |
remote_file = local_file[length..-1] | |
end | |
else | |
remote_file = local_file | |
end | |
case modifier | |
when 'A', 'M', 'C' | |
puts "Uploading #{local_file}" | |
self.put(local_file, remote_file) | |
when 'D' | |
puts "Deleting #{local_file}" | |
self.delete(remote_file) | |
end | |
@remote_files.delete(local_file) | |
end | |
end | |
def upload_revision_file | |
puts 'Uploading REVISION' | |
self.put(@options['revision_file'].path, "REVISION") | |
end | |
def upload_maintenance_file | |
self.put(@options['maintenance_file'], @options['maintenance_deploy_to']) | |
end | |
def remove_maintenance_file | |
self.put(@options['maintenance_deploy_to'], @options['maintenance_deploy_to']) | |
end | |
end | |
class SFTP < Service | |
def initialize(uri, options) | |
super(uri, options) | |
@path = options['path'].empty? ? options['chdir'] : options['path'] | |
@remote_directories = {} | |
end | |
def connect | |
puts "Connecting to #{@host}" | |
sftp_options = {} | |
# sftp_options[:verbose] = :debug | |
sftp_options[:port] = @uri.port if @uri.port | |
sftp_options[:password] = @options['password'] if @options['password'] | |
@sftp = Net::SFTP.start(@options['host'], @options['user'], sftp_options) | |
end | |
def disconnect | |
# @sftp.close! | |
end | |
def put(local_file, remote_file) | |
dir = File.dirname("#{@path}/#{remote_file}") | |
unless @remote_directories[dir] | |
self.mkdir_p(dir) | |
@remote_directories[dir] = true | |
end | |
@sftp.upload!(local_file, "#{@path}/#{remote_file}") | |
end | |
def delete(remote_file) | |
@sftp.remove!("#{@remote_file}") | |
end | |
def get_remote_revision | |
remote_revision = false | |
begin | |
@sftp.file.open("#{@path}/REVISION", "r") do |f| | |
remote_revision = f.gets.strip | |
end | |
puts "Remote Revision: #{remote_revision}" | |
rescue Net::SFTP::StatusException => e | |
raise e unless e.code == 2 && e.description == "no such file" | |
remote_revision = false | |
end | |
remote_revision | |
end | |
def dir_empty? | |
@sftp.dir.list("#{@path}", '*').empty? | |
end | |
def mkdir_p(directory) | |
begin | |
parent = File.dirname(directory) | |
files = @sftp.dir.glob(parent, "*") | |
if directory == "." || files.any? { |a| a == directory } | |
return | |
else | |
puts "Creating Directory #{directory}" | |
@sftp.mkdir(directory) | |
end | |
rescue Net::SFTP::StatusException | |
self.mkdir_p(File.dirname(directory)) | |
begin | |
@sftp.mkdir(directory) | |
rescue Net::SFTP::StatusException | |
# pass | |
end | |
rescue Net::SFTP::StatusException | |
begin | |
puts "Creating Directory #{directory}" | |
@sftp.mkdir(directory) | |
rescue Net::SFTP::StatusException => e | |
self.mkdir_p(File.dirname(directory)) | |
@sftp.mkdir(directory) | |
end | |
end | |
end | |
end | |
class FTP < Service | |
def initialize(uri, options) | |
super(uri, options) | |
@remote_directories = {} | |
@port = options['port'] || uri.port || 21 | |
end | |
def connect | |
puts "Connecting to #{@host}" | |
@ftp = Net::FTP.new | |
if @options['passive'] | |
@ftp.passive = true | |
end | |
if @options['debug_mode'] | |
@ftp.debug_mode = true | |
end | |
@ftp.binary = true | |
@ftp.connect(@options['host'], @options['port']) | |
@ftp.login(@options['user'], @options['password']) | |
@ftp.chdir(@options['path'].empty? ? @options['chdir'] : '/' + @options['path']) | |
end | |
def disconnect | |
@ftp.quit | |
end | |
def put(local_file, remote_file) | |
dir = File.dirname(remote_file) | |
unless @remote_directories[dir] | |
self.mkdir_p(dir) | |
@remote_directories[dir] = true | |
end | |
@ftp.put(local_file, remote_file) | |
end | |
def delete(remote_file) | |
begin | |
@ftp.delete(remote_file) | |
rescue Net::FTPPermError, Net::FTPReplyError => e | |
# | |
end | |
end | |
def get_remote_revision | |
remote_revision = false | |
begin | |
@ftp.get('REVISION', Tempfile.new(@host).path) do |line| | |
remote_revision = line.strip | |
end | |
puts "Remote Revision: #{remote_revision}" | |
rescue Net::FTPPermError => e | |
raise e unless e.message[0..2] == '550' | |
remote_revision = false | |
end | |
remote_revision | |
end | |
def dir_empty? | |
@ftp.list('*').empty? | |
end | |
def mkdir_p(directory) | |
begin | |
parent = File.dirname(directory) | |
files = @ftp.nlst(parent) | |
if directory == "." || files.any? { |a| a == directory } | |
return | |
else | |
puts "Creating Directory #{directory}" | |
@ftp.mkdir(directory) | |
end | |
rescue Net::FTPPermError | |
self.mkdir_p(File.dirname(directory)) | |
begin | |
@ftp.mkdir(directory) | |
rescue Net::FTPPermError | |
# pass | |
end | |
rescue Net::FTPTempError | |
begin | |
puts "Creating Directory #{directory}" | |
@ftp.mkdir(directory) | |
rescue Net::FTPPermError => e | |
self.mkdir_p(File.dirname(directory)) | |
@ftp.mkdir(directory) | |
end | |
end | |
end | |
end | |
class FTPS < Service | |
def initialize(uri, options) | |
super(uri, options) | |
@remote_directories = {} | |
@port = options['port'] || uri.port || 21 | |
end | |
def connect | |
puts "Connecting to #{@host} with TLS" | |
@ftps = Net::FTPTLS.new | |
if @options['passive'] | |
@ftps.passive = true | |
end | |
if @options['debug_mode'] | |
@ftps.debug_mode = true | |
end | |
@ftps.binary = true | |
@ftps.connect(@options['host'], @options['port']) | |
@ftps.login(@options['user'], @options['password']) | |
@ftps.chdir(@options['path'].empty? ? @options['chdir'] : '/' + @options['path']) | |
end | |
def disconnect | |
@ftps.quit | |
end | |
def put(local_file, remote_file) | |
dir = File.dirname(remote_file) | |
unless @remote_directories[dir] | |
self.mkdir_p(dir) | |
@remote_directories[dir] = true | |
end | |
@ftps.put(local_file, remote_file) | |
end | |
def delete(remote_file) | |
begin | |
@ftps.delete(remote_file) | |
rescue Net::FTPPermError, Net::FTPReplyError => e | |
end | |
end | |
def get_remote_revision | |
remote_revision = false | |
begin | |
@ftps.get('REVISION', Tempfile.new(@host).path) do |line| | |
remote_revision = line.strip | |
end | |
puts "Remote Revision: #{remote_revision}" | |
rescue Net::FTPPermError => e | |
raise e unless e.message[0..2] == '550' | |
remote_revision = @options['revision'] | |
end | |
remote_revision | |
end | |
def mkdir_p(directory) | |
begin | |
parent = File.dirname(directory) | |
files = @ftps.nlst(parent) | |
if directory == "." || files.any? { |a| a == directory } | |
return | |
else | |
puts "Creating Directory #{directory}" | |
@ftps.mkdir(directory) | |
end | |
rescue Net::FTPPermError | |
self.mkdir_p(File.dirname(directory)) | |
begin | |
@ftps.mkdir(directory) | |
rescue Net::FTPPermError | |
# pass | |
end | |
rescue Net::FTPTempError | |
begin | |
puts "Creating Directory #{directory}" | |
@ftps.mkdir(directory) | |
rescue Net::FTPPermError => e | |
self.mkdir_p(File.dirname(directory)) | |
@ftps.mkdir(directory) | |
end | |
end | |
end | |
end | |
end | |
module Net | |
class FTPTLS < FTP | |
def login(user = "anonymous", passwd = nil, acct = nil) | |
@ctx = OpenSSL::SSL::SSLContext.new('SSLv3') | |
@ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE | |
voidcmd("AUTH TLS") | |
@sock = OpenSSL::SSL::SSLSocket.new(@sock, @ctx) | |
@sock.connect | |
super(user, passwd, acct) | |
voidcmd("PBSZ 0") | |
# voidcmd("PROT P") # uncomment if you want data encypted too. | |
end | |
end | |
end | |
services.each do |uri, options| | |
next if options['skip'] | |
puts uri | |
uri = URI.parse(uri) | |
options['revision'] = revision | |
options['revision_file' ] = revision_file | |
options['scheme'] = uri.scheme if uri.scheme | |
options['user'] = uri.user if uri.user | |
options['password'] = uri.password if uri.password | |
options['host'] = uri.host if uri.host | |
options['port'] = uri.port if uri.port | |
options['path'] = uri.path if uri.path | |
$ignore_file_list = ($ignore_file_list | options['ignore_file_list']) if options['ignore_file_list'] | |
options.merge!(settings) | |
pwd = Dir.pwd | |
local_path = options['local_path'] | |
if local_path | |
if local_path[0..0] == '/' | |
raise "Field `local_path` cannot be an absolute path" | |
end | |
else | |
local_path = '' | |
end | |
if local_path.length != 0 | |
if local_path[-1..-1] != '/' | |
local_path += '/' | |
end | |
length = local_path.length | |
else | |
length = 0 | |
end | |
options['length'] = length | |
service = case options['scheme'] | |
when 'sftp' then Git::SFTP.new(uri, options) | |
when 'ftp' then Git::FTP.new(uri, options) | |
when 'ftps' then Git::FTPS.new(uri, options) | |
end | |
service.automate | |
unless service.failures.empty? | |
service_failures[uri] = service.failures | |
end | |
end | |
unless service_failures.empty? | |
puts "Failures" | |
puts service_failures.inspect | |
service_failures.each do |uri, failures| | |
failures.each do |failure| | |
puts "#{uri}: #{failure.class} #{failure}" | |
end | |
end | |
end | |
revision_file.delete |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment