Class: S3TarBackup::Main

Inherits:
Object
  • Object
show all
Defined in:
lib/s3_tar_backup.rb

Constant Summary collapse

UPLOAD_TRIES =
5

Instance Method Summary collapse

Instance Method Details

#backup(config, backup, verbose = false) ⇒ Object



190
191
192
193
194
195
196
197
# File 'lib/s3_tar_backup.rb', line 190

def backup(config, backup, verbose=false)
  exec(backup.backup_cmd(verbose))
  puts "Uploading #{config[:bucket]}/#{config[:dest_prefix]}/#{File.basename(backup.archive)} (#{bytes_to_human(File.size(backup.archive))})"
  upload(backup.archive, config[:bucket], "#{config[:dest_prefix]}/#{File.basename(backup.archive)}")
  puts "Uploading snar (#{bytes_to_human(File.size(backup.snar_path))})"
  upload(backup.snar_path, config[:bucket], "#{config[:dest_prefix]}/#{File.basename(backup.snar)}")
  File.delete(backup.archive)
end

#backup_full(config, verbose = false) ⇒ Object



182
183
184
185
186
187
188
# File 'lib/s3_tar_backup.rb', line 182

def backup_full(config, verbose=false)
  puts "Starting new full backup"
  backup = Backup.new(config[:backup_dir], config[:name], config[:sources], config[:exclude], config[:compression], config[:gpg_key])
  # Nuke the snar file -- forces a full backup
  File.delete(backup.snar_path) if File.exists?(backup.snar_path)
  backup(config, backup, verbose)
end

#backup_incr(config, verbose = false) ⇒ Object

Config should have the keys backup_dir, name, soruces, exclude, bucket, dest_prefix



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/s3_tar_backup.rb', line 158

def backup_incr(config, verbose=false)
  puts "Starting new incremental backup"
  backup = Backup.new(config[:backup_dir], config[:name], config[:sources], config[:exclude], config[:compression], config[:gpg_key])

  # Try and get hold of the snar file
  unless backup.snar_exists?
    puts "Failed to find snar file. Attempting to download..."
    s3_snar = "#{config[:dest_prefix]}/#{backup.snar}"
    object = @s3.buckets[config[:bucket]].objects[s3_snar]
    if object.exists?
      puts "Found file on S3. Downloading"
      open(backup.snar_path, 'wb') do |f|
        object.read do |chunk|
          f.write(chunk)
        end
      end
    else
      puts "Failed to download snar file. Defaulting to full backup"
    end
  end

  backup(config, backup, verbose)
end

#bytes_to_human(n) ⇒ Object



305
306
307
308
309
310
311
312
# File 'lib/s3_tar_backup.rb', line 305

def bytes_to_human(n)
  count = 0
  while n >= 1014 && count < 4
    n /= 1024.0
    count += 1
  end
  format("%.2f", n) << %w(B KB MB GB TB)[count]
end

#connect_s3(access_key, secret_key, region) ⇒ Object



80
81
82
83
# File 'lib/s3_tar_backup.rb', line 80

def connect_s3(access_key, secret_key, region)
  warn "No AWS region specified (config key settings.s3_region). Assuming eu-west-1" unless region
  AWS::S3.new(access_key_id: access_key, secret_access_key: secret_key, region: region || 'eu-west-1')
end

#exec(cmd) ⇒ Object



314
315
316
317
# File 'lib/s3_tar_backup.rb', line 314

def exec(cmd)
  puts "Executing: #{cmd}"
  system(cmd)
end

#full_required?(interval_str, objects) ⇒ Boolean

Returns:

  • (Boolean)


300
301
302
303
# File 'lib/s3_tar_backup.rb', line 300

def full_required?(interval_str, objects)
  time = parse_interval(interval_str)
  objects.select{ |o| o[:type] == :full && o[:date] > time }.empty?
end

#gen_backup_config(profile, config) ⇒ Object



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/s3_tar_backup.rb', line 85

def gen_backup_config(profile, config)
  bucket, dest_prefix = (config.get("profile.#{profile}.dest", false) || config['settings.dest']).split('/', 2)
  gpg_key = config.get("profile.#{profile}.gpg_key", false) || config['settings.gpg_key']
  backup_config = {
    :backup_dir => config.get("profile.#{profile}.backup_dir", false) || config['settings.backup_dir'],
    :name => profile,
    :gpg_key => gpg_key && !gpg_key.empty? ? gpg_key : nil,
    :sources => [*config.get("profile.#{profile}.source", [])] + [*config.get("settings.source", [])],
    :exclude => [*config.get("profile.#{profile}.exclude", [])] + [*config.get("settings.exclude", [])],
    :bucket => bucket,
    :dest_prefix => dest_prefix.chomp('/'),
    :pre_backup => [*config.get("profile.#{profile}.pre-backup", [])] + [*config.get('settings.pre-backup', [])],
    :post_backup => [*config.get("profile.#{profile}.post-backup", [])] + [*config.get('settings.post-backup', [])],
    :full_if_older_than => config.get("profile.#{profile}.full_if_older_than", false) || config['settings.full_if_older_than'],
    :remove_older_than => config.get("profile.#{profile}.remove_older_than", false) || config.get('settings.remove_older_than', false),
    :remove_all_but_n_full => config.get("profile.#{profile}.remove_all_but_n_full", false) || config.get('settings.remove_all_but_n_full', false),
    :compression => (config.get("profile.#{profile}.compression", false) || config.get('settings.compression', 'bzip2')).to_sym,
    :always_full => config.get('settings.always_full', false) || config.get("profile.#{profile}.always_full", false),
  }
  backup_config
end

#get_objects(bucket, prefix, profile) ⇒ Object



281
282
283
284
285
286
# File 'lib/s3_tar_backup.rb', line 281

def get_objects(bucket, prefix, profile)
  objects = @s3.buckets[bucket].objects.with_prefix(prefix).map do |object|
    Backup.parse_object(object, profile)
  end
  objects.compact.sort_by{ |o| o[:date] }
end

#parse_interval(interval_str) ⇒ Object



288
289
290
291
292
293
294
295
296
297
298
# File 'lib/s3_tar_backup.rb', line 288

def parse_interval(interval_str)
  time = Time.now
  time -= $1.to_i if interval_str =~ /(\d+)s/
  time -= $1.to_i*60 if interval_str =~ /(\d+)m/
  time -= $1.to_i*3600 if interval_str =~ /(\d+)h/
  time -= $1.to_i*86400 if interval_str =~ /(\d+)D/
  time -= $1.to_i*604800 if interval_str =~ /(\d+)W/
  time -= $1.to_i*2592000 if interval_str =~ /(\d+)M/
  time -= $1.to_i*31536000 if interval_str =~ /(\d+)Y/
  time
end

#perform_backup(opts, prev_backups, backup_config) ⇒ Object



107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/s3_tar_backup.rb', line 107

def perform_backup(opts, prev_backups, backup_config)
  puts "===== Backing up profile #{backup_config[:name]} ====="
  backup_config[:pre_backup].each_with_index do |cmd, i|
    puts "Executing pre-backup hook #{i+1}"
    exec(cmd)
  end
  full_required = full_required?(backup_config[:full_if_older_than], prev_backups)
  puts "Last full backup is too old. Forcing a full backup" if full_required && !opts[:full] && backup_config[:always_full]
  if full_required || opts[:full] || backup_config[:always_full]
    backup_full(backup_config, opts[:verbose])
  else
    backup_incr(backup_config, opts[:verbose])
  end
  backup_config[:post_backup].each_with_index do |cmd, i|
    puts "Executing post-backup hook #{i+1}"
    exec(cmd)
  end
end

#perform_cleanup(prev_backups, backup_config) ⇒ Object



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
# File 'lib/s3_tar_backup.rb', line 126

def perform_cleanup(prev_backups, backup_config)
  puts "===== Cleaning up profile #{backup_config[:name]} ====="
  remove = []
  if age_str = backup_config[:remove_older_than]
    age = parse_interval(age_str)
    remove = prev_backups.select{ |o| o[:date] < age }
    # Don't want to delete anything before the last full backup
    unless remove.empty?
      kept = remove.slice!(remove.rindex{ |o| o[:type] == :full }..-1).count
      puts "Keeping #{kept} old backups as part of a chain" if kept > 1
    end
  elsif keep_n = backup_config[:remove_all_but_n_full]
    keep_n = keep_n.to_i
    # Get the date of the last full backup to keep
    if last_full_to_keep = prev_backups.select{ |o| o[:type] == :full }[-keep_n]
      # If there is a last full one...
      remove = prev_backups.select{ |o| o[:date] < last_full_to_keep[:date] }
    end
  end

  if remove.empty?
    puts "Nothing to do"
  else
    puts "Removing #{remove.count} old backup files"
  end
  remove.each do |object|
    @s3.buckets[backup_config[:bucket]].objects["#{backup_config[:dest_prefix]}/#{object[:name]}"].delete
  end
end

#perform_list_backups(prev_backups, backup_config) ⇒ Object



254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
# File 'lib/s3_tar_backup.rb', line 254

def perform_list_backups(prev_backups, backup_config)
  # prev_backups alreays contains just the files for the current profile
  puts "===== Backups list for #{backup_config[:name]} ====="
  puts "Type: N:  Date:#{' '*18}Size:       Chain Size:   Format:   Encrypted:\n\n"
  prev_type = ''
  total_size = 0
  chain_length = 0
  chain_cum_size = 0
  prev_backups.each do |object|
    type = object[:type] == prev_type && object[:type] == :incr ? " -- " : object[:type].to_s.capitalize
    prev_type = object[:type]
    chain_length += 1
    chain_length = 0 if object[:type] == :full
    chain_cum_size = 0 if object[:type] == :full
    chain_cum_size += object[:size]

    chain_length_str = (chain_length == 0 ? '' : chain_length.to_s).ljust(3)
    chain_cum_size_str = (object[:type] == :full ? '' : bytes_to_human(chain_cum_size)).ljust(8)
    puts "#{type}  #{chain_length_str} #{object[:date].strftime('%F %T')}    #{bytes_to_human(object[:size]).ljust(8)}    " \
      "#{chain_cum_size_str}      #{object[:compression].to_s.ljust(7)}   #{object[:encryption] ? 'Y' : 'N'}"
    total_size += object[:size]
  end
  puts "\n"
  puts "Total size: #{bytes_to_human(total_size)}"
  puts "\n"
end

#perform_restore(opts, prev_backups, backup_config) ⇒ Object



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
# File 'lib/s3_tar_backup.rb', line 216

def perform_restore(opts, prev_backups, backup_config)
  puts "===== Restoring profile #{backup_config[:name]} ====="
  # If restore date given, parse
  if opts[:restore_date_given]
    m = opts[:restore_date].match(/(\d\d\d\d)(\d\d)(\d\d)?(\d\d)?(\d\d)?(\d\d)?/)
    raise "Unknown date format in --restore-to" if m.nil?
    restore_to = Time.new(*m[1..-1].map{ |s| s.to_i if s })
  else
    restore_to = Time.now
  end

  # Find the index of the first backup, incremental or full, before that date
  restore_end_index = prev_backups.rindex{ |o| o[:date] < restore_to }
  raise "Failed to find a backup for that date" unless restore_end_index

  # Find the first full backup before that one
  restore_start_index = prev_backups[0..restore_end_index].rindex{ |o| o[:type] == :full }

  restore_dir = opts[:restore].chomp('/') << '/'

  Dir.mkdir(restore_dir) unless Dir.exists?(restore_dir)
  raise "Destination dir is not a directory" unless File.directory?(restore_dir)

  prev_backups[restore_start_index..restore_end_index].each do |object|
    puts "Fetching #{backup_config[:bucket]}/#{backup_config[:dest_prefix]}/#{object[:name]} (#{bytes_to_human(object[:size])})"
    dl_file = "#{backup_config[:backup_dir]}/#{object[:name]}"
    open(dl_file, 'wb') do |f|
      @s3.buckets[backup_config[:bucket]].objects["#{backup_config[:dest_prefix]}/#{object[:name]}"].read do |chunk|
        f.write(chunk)
      end
    end
    puts "Extracting..."
    exec(Backup.restore_cmd(restore_dir, dl_file, opts[:verbose]))

    File.delete(dl_file)
  end
end

#runObject



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
# File 'lib/s3_tar_backup.rb', line 11

def run
  opts = Trollop::options do
    version VERSION
    banner "Backs up files to, and restores files from, Amazon's S3 storage, using tar incremental backups\n\n" \
      "Usage:\ns3-tar-backup -c config.ini [-p profile] --backup [--full] [-v]\n" \
      "s3-tar-backup -c config.ini [-p profile] --cleanup [-v]\n" \
      "s3-tar-backup -c config.ini [-p profile] --restore restore_dir\n\t[--restore_date date] [-v]\n" \
      "s3-tar-backup -c config.ini [-p profile] --backup-config [--verbose]\n" \
      "s3-tar-backup -c config.ini [-p profile] --list-backups\n\n" \
      "Option details:\n"
    opt :config, "Configuration file", :short => 'c', :type => :string, :required => true
    opt :backup, "Make an incremental backup"
    opt :full, "Make the backup a full backup"
    opt :profile, "The backup profile(s) to use (default all)", :short => 'p', :type => :strings
    opt :cleanup, "Clean up old backups"
    opt :restore, "Restore a backup to the specified dir", :type => :string
    opt :restore_date, "Restore a backup from the specified date. Format YYYYMM[DD[hh[mm[ss]]]]", :type => :string
    opt :backup_config, "Backs up the specified configuration file"
    opt :list_backups, "List the stored backup info for one or more profiles"
    opt :verbose, "Show verbose output", :short => 'v'
    conflicts :backup, :cleanup, :restore, :backup_config, :list_backups
  end


  Trollop::die "--full requires --backup" if opts[:full] && !opts[:backup]
  Trollop::die "--restore-date requires --restore" if opts[:restore_date_given] && !opts[:restore_given]
  unless opts[:backup] || opts[:cleanup] || opts[:restore_given] || opts[:backup_config] || opts[:list_backups]
    Trollop::die "Need one of --backup, --cleanup, --restore, --backup-config, --list-backups"
  end

  begin
    raise "Config file #{opts[:config]} not found" unless File.exists?(opts[:config])
    config = IniParser.new(opts[:config]).load
    profiles = opts[:profile] || config.find_sections(/^profile\./).keys.map{ |k| k.to_s.split('.', 2)[1] }
    @s3 = connect_s3(
      ENV['AWS_ACCESS_KEY_ID'] || config['settings.aws_access_key_id'],
      ENV['AWS_SECRET_ACCESS_KEY'] || config['settings.aws_secret_access_key'],
      config.get('settings.aws_region', false)
    )

    # This is a bit of a special case
    if opts[:backup_config]
      dest = config.get('settings.dest', false)
      raise "You must specify a single profile (used to determine the location to back up to) " \
        "if backing up config and dest key is not in [settings]" if !dest && profiles.count != 1
      dest ||= config["profile.#{profiles[0]}.dest"]
      puts "===== Backing up config file #{opts[:config]} ====="
      bucket, prefix = (config.get('settings.dest', false) || config["profile.#{profiles[0]}.dest"]).split('/', 2)
      puts "Uploading #{opts[:config]} to #{bucket}/#{prefix}/#{File.basename(opts[:config])}"
      upload(opts[:config], bucket, "#{prefix}/#{File.basename(opts[:config])}")
      return
    end

    profiles.dup.each do |profile|
      raise "No such profile: #{profile}" unless config.has_section?("profile.#{profile}")
      opts[:profile] = profile
      backup_config = gen_backup_config(opts[:profile], config)
      prev_backups = get_objects(backup_config[:bucket], backup_config[:dest_prefix], opts[:profile])
      perform_backup(opts, prev_backups, backup_config) if opts[:backup]
      perform_cleanup(prev_backups, backup_config) if opts[:backup] || opts[:cleanup]
      perform_restore(opts, prev_backups, backup_config) if opts[:restore_given]
      perform_list_backups(prev_backups, backup_config) if opts[:list_backups]
    end
  rescue Exception => e
    raise e
    Trollop::die e.to_s
  end
end

#upload(source, bucket, dest_name) ⇒ Object



199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/s3_tar_backup.rb', line 199

def upload(source, bucket, dest_name)
  tries = 0
  begin
    @s3.buckets[bucket].objects.create(dest_name, Pathname.new(source))
  rescue Errno::ECONNRESET => e
    tries += 1
    if tries <= UPLOAD_TRIES
      puts "Upload Exception: #{e}"
      puts "Retrying #{tries}/#{UPLOAD_TRIES}..."
      retry
    else
      raise e
    end
  end
  puts "Succeeded" if tries > 0
end