darthapo (owner)

Revisions

gist: 2561 Download_button fork
public
Public Clone URL: git://gist.github.com/2561.git
Embed All Files: show embed
sftp.thor.rb #
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# module: sftp
 
# A generic Thor module for sftp syncing.
#
# 1. Call `thor sftp:setup` to create config file.
# 2. Edit the config file
# 3. Call `tor sftp:sync` start the sync
#
# Ze end.
 
require 'rubygems'
gem 'net-sftp', '<2.0.0'
require 'net/sftp'
require 'yaml'
require 'find'
 
class Sftp < Thor
 
  desc "upload ENV", "Synchronous a local folder with a remote server as defined in an sftp.yaml file. (ENV defaults to 'default')."
  method_options :verbose => :boolean, :silent => :boolean, :dry => :boolean
  def upload(*args) #env='', opts={}
    env = (args.first.is_a?(Hash)) ? 'default' : args.shift
    opts = args.last
    if File.exists?('sftp.yaml')
      @dry = opts["dry"] || false
      if @dry
        @verbose = true
        @silent = false
      else
        @verbose = opts['verbose'] || false
        @silent = opts['silent'] || false
      end
      begin
        @config = YAML.load( File.open( 'sftp.yaml' ) )
      rescue
        puts "Error: Unable to parse 'sftp.yaml' file."
        exit(1)
      end
      if @config.has_key?(env)
        @settings = @config[env]
        require_keys = %w(host user path) # bare minimums
        if (require_keys - @settings.keys).length == 0
          sync_it(@settings)
        else
          puts "Error: The required key(s) '#{(require_keys - @settings.keys).join("', '")}' are missing."
        end
      else
        puts "Error: There is no definition for the '#{env}' environment."
      end
    else
      puts "Error: 'sftp.yaml' file not found. You may run 'thor sftp:setup' to create one."
    end
  end
 
 
  desc "setup", "Setup a folder for remote synchronization."
  def setup
    if File.exists?("sftp.yaml")
      puts "sftp.yaml already exists!"
      exit(1)
    end
    settings =<<-EOYAML
default: # <- Environment can be whatever you'd like. However 'default' is used if you don't specify a target.
host: YOUR_DOMAIN.com
user: YOUR_USERNAME
#pass: YOUR_PASSWORD # OPTIONAL (will prompt if not specified)
path: www # path on the server to the web root
checksum_file: .checksums
folder: . # Local folder to sync from
ignore: [.svn, .git, .DS_Store, sftp.yaml, .checksums]
EOYAML
    File.open('sftp.yaml', 'w') do |f|
      f.write( settings )
    end
    `mate sftp.yaml`
    puts "Done."
  end
  
  
  # ===============
  # = Sync Method =
  # ===============
  def sync_it(settings)
    puts_if_verbose "Generating local checksums..."
    checksum_file = settings.fetch('checksum_file', ".checksums")
    ignore_these = settings.fetch('ignore', [])
    local_checksums = checksum_generate_from(settings.fetch('folder', '.'), ignore_these)
 
    # We use this to upload the latest checksums
    File.open( local_path(checksum_file), 'w' ) do |f|
      f.write local_checksums.to_yaml
    end
 
    # Fetch password....
    valid_pass = settings.has_key?('pass')
    while !valid_pass
      puts
      print "Specify password for #{ settings['host'] }: "
      password = STDIN.gets.chomp
      valid_pass = (password.length > 0)
      if valid_pass
        settings['pass'] = password
      else
        puts "Password cannot be blank."
      end
    end
    
    puts_if_verbose "Connecting to #{settings['host']}..."
    begin
    
      Net::SFTP.start(settings['host'], settings['user'], settings['pass'], :timeout=>settings.fetch('timeout', 30)) do |sftp|
      
        puts_if_verbose "Fetching checksums...", '.', true
        remote_checksums = {}
        begin
          checksums_src = ''
          sftp.open_handle( remote_path(checksum_file)) do |handle|
            checksums_src = sftp.read(handle)
          end
          remote_checksums = YAML::load(checksums_src) unless checksums_src.nil? or checksums_src==''
        rescue Net::SFTP::Operations::StatusException=>se
          # server's checksum.yml is missing -- don't worry about it, we'll upload it when we're done.
        end
      
        puts_if_verbose "Comparing checksum data..."
        to_upload, to_remove = checksum_diff(local_checksums, remote_checksums)
      
        if to_upload.length > 0 or to_remove.length > 0
          puts_if_verbose "Differences found:"
          to_upload.sort.each {|f| puts_if_verbose " - (Upload) #{f}" }
          to_remove.sort.each {|f| puts_if_verbose " - (Delete) #{f}" }
          if @dry
            puts "Those files would be effected, if this weren't a dry run."
            puts "Done."
            exit(0)
          end
          puts_if_verbose "Beginning sync..."
        
          to_upload.each do |filename|
            begin
              puts_if_verbose " - #{remote_path(filename)}", '+', true
 
              dir_name = File.dirname(filename)
              dir_segs = dir_name.split('/')
              prog_path = []
              dir_segs.each do |partial_dir|
                begin
                  prog_path << partial_dir
                  sftp.mkdir( remote_path( prog_path ), :permissions=>0755 )
                  puts_if_verbose " + #{remote_path( prog_path )}"
                rescue Net::SFTP::Operations::StatusException=>se
                  # don't worry about it
                end
              end
 
              sftp.put_file local_path(filename), remote_path(filename)
              sftp.open_handle( remote_path(filename) ) do |handle|
                sftp.fsetstat( handle, :permissions=>0644 )
              end
            rescue Net::SFTP::Operations::StatusException=>se
              puts_if_verbose " ! Error uploading '#{filename}': #{se.description}"
              puts_if_verbose; puts_if_verbose "Halted execution of upload."
              exit(1)
            end
          end
        
          to_remove.each do |filename|
            begin
              sftp.remove remote_path(filename)
              puts_if_verbose " x #{remote_path(filename)}", 'x', true
            rescue
              puts_if_verbose " ! Error removing '#{filename}': #{$!}"
            end
          end
 
          begin
            sftp.put_file local_path(checksum_file), remote_path(checksum_file)
            sftp.open_handle( remote_path(checksum_file) ) do |handle|
              sftp.fsetstat( handle, :permissions=>0644 )
            end
          rescue
            puts_if_verbose " ! Error uploading checksum file: #{$!}", '!', true
          end
 
        
          summary = "\n#{to_upload.length} file(s) uploaded"
          summary += ", #{to_remove.length} files(s) deleted" if to_remove.length > 0
        
          puts summary
        else
          puts "\nNo changes made. The server is up to date!"
        end
      end
    rescue Net::SSH::AuthenticationFailed=>ae
      puts "Authentication failed!"
    end
  end
  
  def remote_path(path)
    [@settings['path'], path].flatten.join('/')
  end
  
  def local_path(path)
    File.join(@settings.fetch('folder', '.'), path)
  end
  
  
  # returns [files_to_upload, files_to_delete]
  def checksum_diff(source={}, target={})
    # look for differences...
    src_files = source.fetch('files', {})
    tgt_files = target.fetch('files', {})
    to_update = []; to_delete = []; to_upload = []
 
    tgt_files.each do |filename, checksum|
      if src_files.has_key? filename
        to_update << filename unless src_files[filename] == checksum
      else
        to_delete << filename
      end
    end
 
    to_upload = src_files.keys - tgt_files.keys
 
    # returns [files_to_upload, files_to_delete]
    [[to_upload, to_update].flatten, to_delete]
  end
 
  # Create the checksums...
  def checksum_generate_from(cache_dir, exclusions=[])
    checksums = { 'generated_on'=>Time.now, 'files'=>{} }
    Find.find( cache_dir ) do |f|
      next if File.directory?( f )
      relative_path = f.gsub("#{cache_dir}/", '')
      tester = file_fails_test(relative_path, exclusions)
      next if tester
      checksums['files'][relative_path] = Digest::MD5.hexdigest( File.read(f) )
    end
    checksums
  end
  
  def file_fails_test(path, exclusions)
    return true if exclusions.include?(path)
    matches = exclusions.select {|ignore| path.match(ignore) }
    return true if matches.nil?
    return (matches.length > 0) ? true : false
  end
  
 
  def puts_if_verbose(msg='', alt_char='.', server_progress=false)
    if @verbose
      puts(msg)
    elsif !@silent
      if server_progress
        STDOUT.print(alt_char)
        STDOUT.flush
      end
    end
  end
  
end