awshucks 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|