awshucks 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGES +4 -0
- data/LICENSE +19 -0
- data/README +32 -0
- data/Rakefile +14 -0
- data/TODO +143 -0
- data/bin/awshucks +10 -0
- data/lib/awshucks.rb +29 -0
- data/lib/awshucks/cli.rb +76 -0
- data/lib/awshucks/command.rb +98 -0
- data/lib/awshucks/commands.rb +3 -0
- data/lib/awshucks/commands/backup.rb +59 -0
- data/lib/awshucks/commands/backups.rb +25 -0
- data/lib/awshucks/commands/help.rb +22 -0
- data/lib/awshucks/commands/list.rb +39 -0
- data/lib/awshucks/commands/new_config.rb +36 -0
- data/lib/awshucks/commands/reset_metadata_cache.rb +38 -0
- data/lib/awshucks/commands/restore.rb +54 -0
- data/lib/awshucks/config.rb +83 -0
- data/lib/awshucks/ext.rb +23 -0
- data/lib/awshucks/file_info.rb +23 -0
- data/lib/awshucks/file_store.rb +111 -0
- data/lib/awshucks/gemspec.rb +48 -0
- data/lib/awshucks/scanner.rb +58 -0
- data/lib/awshucks/specification.rb +128 -0
- data/lib/awshucks/version.rb +18 -0
- data/resources/awshucks.yml +21 -0
- data/spec/awshucks_spec.rb +7 -0
- data/spec/cli_spec.rb +130 -0
- data/spec/command_spec.rb +111 -0
- data/spec/commands/backup_spec.rb +164 -0
- data/spec/commands/backups_spec.rb +41 -0
- data/spec/commands/help_spec.rb +42 -0
- data/spec/commands/list_spec.rb +77 -0
- data/spec/commands/new_config_spec.rb +102 -0
- data/spec/commands/reset_metadata_cache_spec.rb +93 -0
- data/spec/commands/restore_spec.rb +219 -0
- data/spec/config_spec.rb +152 -0
- data/spec/ext_spec.rb +28 -0
- data/spec/file_info_spec.rb +45 -0
- data/spec/file_store_spec.rb +352 -0
- data/spec/scanner_spec.rb +106 -0
- data/spec/spec_helper.rb +36 -0
- data/spec/specification_spec.rb +41 -0
- metadata +121 -0
@@ -0,0 +1,25 @@
|
|
1
|
+
module Awshucks
|
2
|
+
|
3
|
+
class BackupsCommand < Command
|
4
|
+
self.command = 'backups'
|
5
|
+
self.usage = 'backups'
|
6
|
+
self.description = "Lists the available backups and their configurations"
|
7
|
+
|
8
|
+
def execute(args, config)
|
9
|
+
|
10
|
+
puts "configured backups:"
|
11
|
+
puts ""
|
12
|
+
|
13
|
+
config.backups.each do |backup|
|
14
|
+
|
15
|
+
puts " #{backup.name}:"
|
16
|
+
puts " location: #{backup.location}"
|
17
|
+
puts ""
|
18
|
+
|
19
|
+
end
|
20
|
+
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Awshucks
|
2
|
+
|
3
|
+
class HelpCommand < Command
|
4
|
+
self.command = 'help'
|
5
|
+
self.usage = 'help [commandname]'
|
6
|
+
self.description = "displays this help message or lists help for the given command"
|
7
|
+
|
8
|
+
def execute(args, config)
|
9
|
+
if args.empty?
|
10
|
+
puts config.help_message
|
11
|
+
else
|
12
|
+
if cmd = Command.commands[args.first]
|
13
|
+
puts cmd.option_string
|
14
|
+
else
|
15
|
+
$stderr.puts "Could not find command #{args.first}"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module Awshucks
|
2
|
+
|
3
|
+
class ListCommand < Command
|
4
|
+
self.command = 'list'
|
5
|
+
self.usage = 'list [config name]'
|
6
|
+
self.description = "lists the files stored on S3 for all configs or the specified config"
|
7
|
+
|
8
|
+
def execute(args, config)
|
9
|
+
@config = config
|
10
|
+
list = []
|
11
|
+
if args.empty?
|
12
|
+
list += config.backups
|
13
|
+
else
|
14
|
+
list << config.backup(args.first)
|
15
|
+
end
|
16
|
+
|
17
|
+
list.each {|b| list(b) }
|
18
|
+
|
19
|
+
rescue UnknownBackupError
|
20
|
+
$stderr.puts "Unknown backup: #{args.first}"
|
21
|
+
end
|
22
|
+
|
23
|
+
#######
|
24
|
+
private
|
25
|
+
#######
|
26
|
+
|
27
|
+
attr_reader :config
|
28
|
+
|
29
|
+
def list(backup_info)
|
30
|
+
puts "stored files from #{backup_info.location}:"
|
31
|
+
file_store = FileStore.new(config.connection, config.bucket, backup_info.name)
|
32
|
+
file_store.each_file do |filename|
|
33
|
+
puts " #{filename}"
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Awshucks
|
2
|
+
|
3
|
+
class NewConfigCommand < Command
|
4
|
+
self.command = 'new_config'
|
5
|
+
self.usage = 'new_config [filename]'
|
6
|
+
self.description = "generates a new default config file. filename defaults to awshucks.yml"
|
7
|
+
|
8
|
+
# def custom_options(opts)
|
9
|
+
# opts.on('-f', '--force', "Overwrite the existing config file if it exists") do
|
10
|
+
# parsed_options.overwrite = true
|
11
|
+
# end
|
12
|
+
# end
|
13
|
+
|
14
|
+
def execute(args, config)
|
15
|
+
# option_parser.parse!(args)
|
16
|
+
|
17
|
+
dest_path = File.expand_path(args.empty? ? 'awshucks.yml' : args.first)
|
18
|
+
if File.exists?(dest_path)# && !parsed_options.overwrite
|
19
|
+
$stderr.puts "cannot create #{dest_path}, file already exists"
|
20
|
+
else
|
21
|
+
puts "creating new config file at #{dest_path}"
|
22
|
+
FileUtils.cp(awshucks_config, dest_path )
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
#######
|
27
|
+
private
|
28
|
+
#######
|
29
|
+
|
30
|
+
def awshucks_config
|
31
|
+
File.join(RESOURCE_DIR, 'awshucks.yml')
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
35
|
+
|
36
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module Awshucks
|
2
|
+
|
3
|
+
class ResetMetadataCacheCommand < Command
|
4
|
+
self.command = 'reset_metadata_cache'
|
5
|
+
self.usage = 'reset_metadata_cache [config name]'
|
6
|
+
self.description = "Resets (deletes) the metadata cache for all backups or the specified backup. " +
|
7
|
+
"The next backup will rely on the stored metadata for each file to restore the metadata during the next run, " +
|
8
|
+
"Be aware that this process will be significantly slower than using the metadata cache to begin with."
|
9
|
+
|
10
|
+
def execute(args, config)
|
11
|
+
@config = config
|
12
|
+
list = []
|
13
|
+
if args.empty?
|
14
|
+
list += config.backups
|
15
|
+
else
|
16
|
+
list << config.backup(args.first)
|
17
|
+
end
|
18
|
+
|
19
|
+
list.each {|b| reset(b) }
|
20
|
+
|
21
|
+
rescue UnknownBackupError
|
22
|
+
$stderr.puts "Unknown backup: #{args.first}"
|
23
|
+
end
|
24
|
+
|
25
|
+
#######
|
26
|
+
private
|
27
|
+
#######
|
28
|
+
|
29
|
+
attr_reader :config
|
30
|
+
|
31
|
+
def reset(backup_info)
|
32
|
+
puts "processing #{backup_info.name}:"
|
33
|
+
FileStore.new(config.connection, config.bucket, backup_info.name).reset_cache
|
34
|
+
end
|
35
|
+
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
module Awshucks
|
2
|
+
|
3
|
+
class RestoreCommand < Command
|
4
|
+
self.command = 'restore'
|
5
|
+
self.usage = 'restore <config name> [file_or_dir_pattern [target dir]]'
|
6
|
+
self.description = <<-EOD
|
7
|
+
Restores from the specified config name.
|
8
|
+
Optionally, provide a file or directory name or pattern to specify what files to restore. This can be a wildcard, or if it's a specific path, use the relative path from the configured backup location. If it's a wildcard, don't forget to wrap it in quotes!
|
9
|
+
By default, awshucks will restore files to the location specified in the configuration, but can be directed elsewhere -- provide a target directory to set the destination.
|
10
|
+
EOD
|
11
|
+
|
12
|
+
def execute(args, config)
|
13
|
+
@config = config
|
14
|
+
|
15
|
+
if args.empty?
|
16
|
+
$stderr.puts "Please specify a backup configuration to restore from"
|
17
|
+
else
|
18
|
+
backup = config.backup(args[0])
|
19
|
+
pattern = args[1] || '*'
|
20
|
+
target_dir = args[2] || backup.location
|
21
|
+
restore(backup, pattern, target_dir)
|
22
|
+
end
|
23
|
+
|
24
|
+
rescue UnknownBackupError
|
25
|
+
$stderr.puts "Unknown backup: #{args.first}"
|
26
|
+
end
|
27
|
+
|
28
|
+
#######
|
29
|
+
private
|
30
|
+
#######
|
31
|
+
|
32
|
+
attr_reader :config
|
33
|
+
|
34
|
+
def restore(backup_info, pattern, target_dir)
|
35
|
+
target_dir = File.expand_path(target_dir)
|
36
|
+
|
37
|
+
puts "restoring backup from #{backup_info.location} to #{target_dir}"
|
38
|
+
|
39
|
+
file_store = FileStore.new(config.connection, config.bucket, backup_info.name)
|
40
|
+
|
41
|
+
file_store.each_file do |filename|
|
42
|
+
if File.fnmatch(pattern, filename)
|
43
|
+
puts " restoring #{filename}"
|
44
|
+
file_store.restore(filename, File.join(target_dir, filename))
|
45
|
+
else
|
46
|
+
puts " #{filename} doesn't match '#{pattern}', skipping"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
53
|
+
|
54
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
module Awshucks
|
2
|
+
|
3
|
+
class RequiredConfigError < Exception
|
4
|
+
def initialize(name)
|
5
|
+
super "Missing required configuration setting: #{name}"
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
class UnknownBackupError < Exception;
|
10
|
+
def initialize(name)
|
11
|
+
super "Unknown backup configuration name #{name}"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
class Config
|
16
|
+
|
17
|
+
# class << self
|
18
|
+
#
|
19
|
+
# def load(filename)
|
20
|
+
# new(open(filename))
|
21
|
+
# end
|
22
|
+
#
|
23
|
+
# end
|
24
|
+
|
25
|
+
REQUIRED_CONNECTION_KEYS = %w(access_key_id secret_access_key).freeze
|
26
|
+
REQUIRED_BACKUP_CONFIG_KEYS = %w(location).freeze
|
27
|
+
RESERVED_KEYS = (REQUIRED_CONNECTION_KEYS + %w(bucket)).freeze
|
28
|
+
|
29
|
+
attr_accessor :help_message # piggyback for the global help --> help command
|
30
|
+
|
31
|
+
def initialize(filename)
|
32
|
+
@filename = filename
|
33
|
+
end
|
34
|
+
|
35
|
+
# return the connection info
|
36
|
+
def connection
|
37
|
+
returning({:use_ssl => true, :persistent => false}) do |connection|
|
38
|
+
REQUIRED_CONNECTION_KEYS.each do |key|
|
39
|
+
raise RequiredConfigError, key unless config[key]
|
40
|
+
connection[key.intern] = config[key]
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def bucket
|
46
|
+
returning(config['bucket']) do |bucket|
|
47
|
+
raise RequiredConfigError, 'bucket' unless bucket
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# return the config for the specified backup, with validation
|
52
|
+
def backup(name)
|
53
|
+
raise UnknownBackupError, name if name.in? REQUIRED_CONNECTION_KEYS
|
54
|
+
returning OpenStruct.new(config[name]) do |backup|
|
55
|
+
REQUIRED_BACKUP_CONFIG_KEYS.each do |required|
|
56
|
+
raise RequiredConfigError, required unless backup.send(required)
|
57
|
+
end
|
58
|
+
backup.name = name
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def backups
|
63
|
+
returning [] do |list|
|
64
|
+
(config.keys - RESERVED_KEYS).each do |name|
|
65
|
+
list << backup(name)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
attr_reader :filename
|
73
|
+
|
74
|
+
def config
|
75
|
+
@config ||= YAML.load(File.open(File.expand_path(filename)))
|
76
|
+
rescue Errno::ENOENT # file not found
|
77
|
+
$stderr.puts "config file not found: #{filename}"
|
78
|
+
exit(-1)
|
79
|
+
end
|
80
|
+
|
81
|
+
end
|
82
|
+
|
83
|
+
end
|
data/lib/awshucks/ext.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# miscellaneous core extensions
|
2
|
+
|
3
|
+
class Object
|
4
|
+
def returning(obj)
|
5
|
+
yield obj
|
6
|
+
obj
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
# thanks to http://devel.touset.org/blog/articles/2007/09/02/enumerable-include
|
11
|
+
class Object
|
12
|
+
def in?(enum)
|
13
|
+
enum.include? self
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
class File
|
18
|
+
class << self
|
19
|
+
def md5sum(filename)
|
20
|
+
Digest::MD5.hexdigest(read(filename))
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Awshucks
|
2
|
+
|
3
|
+
class FileInfo
|
4
|
+
|
5
|
+
attr_reader :filename, :full_path, :mtime, :size
|
6
|
+
|
7
|
+
def initialize(filename, full_path, stat)
|
8
|
+
@filename, @full_path= filename, full_path
|
9
|
+
@mtime = stat.mtime.gmtime
|
10
|
+
@size = stat.size
|
11
|
+
end
|
12
|
+
|
13
|
+
def md5sum
|
14
|
+
@md5sum ||= File.md5sum(full_path)
|
15
|
+
end
|
16
|
+
|
17
|
+
def data
|
18
|
+
File.open(full_path)
|
19
|
+
end
|
20
|
+
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
module Awshucks
|
2
|
+
|
3
|
+
class UnknownFileError < Exception; end
|
4
|
+
|
5
|
+
class FileStore
|
6
|
+
|
7
|
+
def initialize(connection_info, bucket_name, prefix)
|
8
|
+
@bucket_name, @prefix = bucket_name, prefix
|
9
|
+
AWS::S3::Base.establish_connection!(connection_info) unless AWS::S3::Base.connected?
|
10
|
+
end
|
11
|
+
|
12
|
+
def each_file
|
13
|
+
bucket.objects(:prefix => prefix).each { |f| yield remove_prefix(f.key) unless f.key == metadata_key }
|
14
|
+
end
|
15
|
+
|
16
|
+
def different_from?(file_info)
|
17
|
+
return true unless info = metadata_for(file_info.filename)
|
18
|
+
if info['mtime'] == file_info.mtime.gmtime.to_s
|
19
|
+
false
|
20
|
+
else
|
21
|
+
if info['md5sum'] == file_info.md5sum
|
22
|
+
info['mtime'] = file_info.mtime.gmtime.to_s # update the metadata cache
|
23
|
+
false
|
24
|
+
else
|
25
|
+
true
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def store(file_info)
|
31
|
+
metadata[file_info.filename] = { 'mtime' => file_info.mtime.to_s, 'md5sum' => file_info.md5sum }
|
32
|
+
s3_metadata = { 'x-amz-meta-mtime' => file_info.mtime.to_s, 'x-amz-meta-md5sum' => file_info.md5sum }
|
33
|
+
AWS::S3::S3Object.store(prefixed_filename(file_info.filename), file_info.data, bucket.name, s3_metadata).success?
|
34
|
+
end
|
35
|
+
|
36
|
+
# stores the value of the file stored on S3 to the target file
|
37
|
+
def restore(filename, target)
|
38
|
+
raise UnknownFileError, "unknown file #{filename}" unless file = bucket[prefixed_filename(filename)]
|
39
|
+
FileUtils.mkdir_p(File.dirname(target))
|
40
|
+
File.open(target, 'w') do |out|
|
41
|
+
file.value do |data|
|
42
|
+
out.write(data)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
# warn if the file's md5 doesn't match after writing it out
|
46
|
+
$stderr.puts "warning: md5sums don't match" if metadata_for(filename)['md5sum'] != File.md5sum(target)
|
47
|
+
end
|
48
|
+
|
49
|
+
def save_cache
|
50
|
+
AWS::S3::S3Object.store(metadata_key, metadata.to_yaml, bucket.name)
|
51
|
+
end
|
52
|
+
|
53
|
+
def reset_cache
|
54
|
+
puts "deleting the metadata cache..."
|
55
|
+
AWS::S3::S3Object.delete(metadata_key, bucket.name)
|
56
|
+
@metadata = {}
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
attr_reader :bucket_name, :prefix
|
62
|
+
|
63
|
+
def bucket
|
64
|
+
@bucket ||= AWS::S3::Bucket.find(bucket_name)
|
65
|
+
rescue AWS::S3::ResponseError => e
|
66
|
+
# this is awkward, but the NoSuchBucket exception itself doesn't get defined until it's raised.
|
67
|
+
raise e unless e.message =~ /bucket does not exist/
|
68
|
+
puts "could not find bucket #{bucket_name}, creating it"
|
69
|
+
retry if AWS::S3::Bucket.create(bucket_name)
|
70
|
+
end
|
71
|
+
|
72
|
+
def metadata
|
73
|
+
@metadata ||= load_metadata_from_cache
|
74
|
+
end
|
75
|
+
|
76
|
+
# returns or caches the metadata for a file from the s3 object if it's not already cached
|
77
|
+
def metadata_for(filename)
|
78
|
+
return metadata[filename] if metadata[filename]
|
79
|
+
return nil unless file = find(filename)
|
80
|
+
metadata[filename] = returning({}) do |info|
|
81
|
+
info['mtime'] = file.metadata['mtime'].to_s
|
82
|
+
info['md5sum'] = file.metadata['md5sum']
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def find(filename)
|
87
|
+
search = prefixed_filename(filename)
|
88
|
+
bucket.objects(:prefix => prefix).detect do |file|
|
89
|
+
file.key == search
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def prefixed_filename(filename)
|
94
|
+
File.join(prefix, filename)
|
95
|
+
end
|
96
|
+
|
97
|
+
def remove_prefix(filename)
|
98
|
+
filename.sub(/\A#{prefix}\//, '')
|
99
|
+
end
|
100
|
+
|
101
|
+
def load_metadata_from_cache
|
102
|
+
bucket[metadata_key] ? YAML.load(bucket[metadata_key].value) : {}
|
103
|
+
end
|
104
|
+
|
105
|
+
def metadata_key
|
106
|
+
prefix + '_metadata.yml'
|
107
|
+
end
|
108
|
+
|
109
|
+
end
|
110
|
+
|
111
|
+
end
|