smashedlife
5/23/2014 - 10:35 PM

git-deploy

git-deploy

#!/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