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.
Files changed (44) hide show
  1. data/CHANGES +4 -0
  2. data/LICENSE +19 -0
  3. data/README +32 -0
  4. data/Rakefile +14 -0
  5. data/TODO +143 -0
  6. data/bin/awshucks +10 -0
  7. data/lib/awshucks.rb +29 -0
  8. data/lib/awshucks/cli.rb +76 -0
  9. data/lib/awshucks/command.rb +98 -0
  10. data/lib/awshucks/commands.rb +3 -0
  11. data/lib/awshucks/commands/backup.rb +59 -0
  12. data/lib/awshucks/commands/backups.rb +25 -0
  13. data/lib/awshucks/commands/help.rb +22 -0
  14. data/lib/awshucks/commands/list.rb +39 -0
  15. data/lib/awshucks/commands/new_config.rb +36 -0
  16. data/lib/awshucks/commands/reset_metadata_cache.rb +38 -0
  17. data/lib/awshucks/commands/restore.rb +54 -0
  18. data/lib/awshucks/config.rb +83 -0
  19. data/lib/awshucks/ext.rb +23 -0
  20. data/lib/awshucks/file_info.rb +23 -0
  21. data/lib/awshucks/file_store.rb +111 -0
  22. data/lib/awshucks/gemspec.rb +48 -0
  23. data/lib/awshucks/scanner.rb +58 -0
  24. data/lib/awshucks/specification.rb +128 -0
  25. data/lib/awshucks/version.rb +18 -0
  26. data/resources/awshucks.yml +21 -0
  27. data/spec/awshucks_spec.rb +7 -0
  28. data/spec/cli_spec.rb +130 -0
  29. data/spec/command_spec.rb +111 -0
  30. data/spec/commands/backup_spec.rb +164 -0
  31. data/spec/commands/backups_spec.rb +41 -0
  32. data/spec/commands/help_spec.rb +42 -0
  33. data/spec/commands/list_spec.rb +77 -0
  34. data/spec/commands/new_config_spec.rb +102 -0
  35. data/spec/commands/reset_metadata_cache_spec.rb +93 -0
  36. data/spec/commands/restore_spec.rb +219 -0
  37. data/spec/config_spec.rb +152 -0
  38. data/spec/ext_spec.rb +28 -0
  39. data/spec/file_info_spec.rb +45 -0
  40. data/spec/file_store_spec.rb +352 -0
  41. data/spec/scanner_spec.rb +106 -0
  42. data/spec/spec_helper.rb +36 -0
  43. data/spec/specification_spec.rb +41 -0
  44. 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
@@ -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