yob 1.0.0

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.
@@ -0,0 +1,27 @@
1
+ = YOB
2
+
3
+ An online backup system for MySQL and PostgreSQL, using GnuPG for encryption and AWS S3 for storage.
4
+
5
+ YOB is pluggable, so it's easy to make different database, encryption and storage engines.
6
+
7
+ == Install
8
+
9
+ You'll need a few libraries installed.
10
+
11
+ aptitude install libgpgme11-dev libsqlite3-dev libxml2-dev libxslt-dev libopenssl-ruby1.8
12
+
13
+ Install yob from rubygems:
14
+
15
+ gem install yob
16
+
17
+ Then:
18
+
19
+ useradd --home-dir /var/lib/yob --create-home yob
20
+
21
+ If you're using MySQL, create ~yob/.my.cnf with mysql username and password and
22
+
23
+ GRANT select,reload,super,replication client ON *.* TO yob@localhost IDENTIFIED BY 'somepassword'
24
+
25
+ == Licence
26
+
27
+ Licenced under the MIT licence. Copyright 2011-2012 YouDo Limited.
data/bin/yob ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'yob'
4
+
5
+ def run
6
+ yob = Yob.new(Yob::Configuration.new(ARGV))
7
+ case ARGV[0]
8
+ when 'full'
9
+ yob.backup :full_backup
10
+ when 'partial'
11
+ yob.backup :partial_backup
12
+ else
13
+ puts "specify 'full' or 'partial' on the command line"
14
+ exit 1
15
+ end
16
+ rescue Yob::Configuration::Error => e
17
+ $stderr.puts e.message
18
+ exit 1
19
+ end
20
+
21
+ run
@@ -0,0 +1,29 @@
1
+ require 'rubygems'
2
+
3
+ class Yob
4
+ def initialize(configuration)
5
+ @configuration = configuration
6
+ @store = @configuration.store_handler_class.new(@configuration)
7
+ @database = @configuration.database_handler_class.new(@configuration)
8
+ @encryption = @configuration.encryption_handler_class.new(@configuration)
9
+ end
10
+
11
+ def backup(type)
12
+ @database.send(type) do |filename, rd|
13
+ storage_pipe = @store.storage_input_pipe(filename)
14
+ if rd
15
+ @encryption.encrypt(rd, storage_pipe)
16
+ storage_pipe.close
17
+ nil
18
+ else
19
+ @encryption.encryption_input_pipe(storage_pipe)
20
+ end
21
+ end
22
+ Process.waitall
23
+ end
24
+ end
25
+
26
+ require 'yob/configuration'
27
+ require 'yob/database'
28
+ require 'yob/encrypt'
29
+ require 'yob/store'
@@ -0,0 +1,66 @@
1
+ require 'yaml'
2
+ require 'optparse'
3
+
4
+ class Yob::Configuration
5
+ Error = Class.new(StandardError)
6
+
7
+ def initialize(argv = [])
8
+ command_line_options = {}
9
+ configuration_path = ["/etc/yob.yml", File.dirname(__FILE__) << "/yob.yml"].detect {|file| File.exists?(file)}
10
+
11
+ OptionParser.new(argv) do |opts|
12
+ opts.banner = "Usage: yob [options] full|partial"
13
+
14
+ opts.on("--debug", "Print debug output") do
15
+ command_line_options["debug"] = true
16
+ end
17
+
18
+ opts.on("-c", "--config CONFIG_FILE", String, "Specify a path to the configuration file") do |file|
19
+ configuration_path = file
20
+ end
21
+
22
+ opts.on_tail("-h", "--help", "Show this message") do
23
+ puts opts
24
+ exit
25
+ end
26
+ end.parse!
27
+
28
+ raise Error, "could not locate a yob.yml file in any of the normal locations" unless configuration_path
29
+
30
+ configuration = YAML.load(IO.read(configuration_path))["configuration"]
31
+ @hash = configuration.merge(command_line_options)
32
+ end
33
+
34
+ def [](key)
35
+ @hash[key.to_s]
36
+ end
37
+
38
+ def fetch(key, default)
39
+ @hash[key.to_s] || default
40
+ end
41
+
42
+ def method_missing(key, *args)
43
+ key = key.to_s
44
+ raise Error, "Required configuration parameter '#{key}' not specified in configuration file" unless @hash.member?(key)
45
+ @hash[key]
46
+ end
47
+
48
+ def database_handler_class
49
+ case database_handler.downcase
50
+ when "postgresql" then Yob::Database::Postgresql
51
+ when "mysql" then Yob::Database::Mysql
52
+ else raise "Unrecognised database_handler"
53
+ end
54
+ end
55
+
56
+ def store_handler_class
57
+ case storage_handler.downcase
58
+ when "aws" then Yob::Store::AWS
59
+ else raise "Unrecognised storage_handler"
60
+ end
61
+ end
62
+
63
+ def encryption_handler_class
64
+ Yob::Encrypt::GnuPG
65
+ end
66
+ end
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ module Yob::Database
4
+ class Base
5
+ attr_reader :configuration
6
+
7
+ def initialize(configuration)
8
+ @configuration = configuration
9
+ end
10
+
11
+ protected
12
+ def random_filename_string
13
+ (0..15).inject(".") {|s, i| s << (97 + rand(26)).chr} if configuration["randomise_filename"]
14
+ end
15
+ end
16
+
17
+ class Mysql < Base
18
+ def initialize(configuration)
19
+ super
20
+
21
+ unless File.directory?(configuration.mysql_log_directory)
22
+ raise Yob::Configuration::Error, "mysql_log_directory does not exist"
23
+ end
24
+
25
+ unless File.exists?(configuration.mysqldump_executable)
26
+ raise Yob::Configuration::Error, "mysqldump_executable does not exist"
27
+ end
28
+ end
29
+
30
+ def full_backup
31
+ writer = yield Time.now.strftime("%Y%m%d-%H%M%S#{random_filename_string}.sql.gpg"), nil
32
+ begin
33
+ puts "[Database::Mysql] dumping all databases to SQL..."
34
+ system("#{configuration.mysqldump_executable} --all-databases --default-character-set=utf8 --skip-opt --create-options --add-drop-database --extended-insert --flush-logs --master-data --quick --single-transaction >&#{writer.fileno}")
35
+ puts "[Database::Mysql] dump completed"
36
+ ensure
37
+ writer.close
38
+ end
39
+ end
40
+
41
+ def partial_backup
42
+ require 'sqlite3'
43
+
44
+ @db = SQLite3::Database.new(configuration.fetch("file_database", "#{File.dirname(__FILE__)}/yob.db"))
45
+ @db.execute("CREATE TABLE IF NOT EXISTS files (id INTEGER PRIMARY KEY AUTOINCREMENT, filename varchar(255) unique not null, file_size integer not null, file_time datetime not null)")
46
+
47
+ files = Dir["#{configuration.mysql_log_directory}/mysql-bin.*"]
48
+ files.each do |filename|
49
+ next if filename[-5..-1] == 'index'
50
+
51
+ stats = File.stat(filename)
52
+ file_time = stats.mtime.strftime("%Y-%m-%d %H:%M:%S")
53
+
54
+ row = @db.get_first_row("SELECT id, file_size, file_time FROM files WHERE filename = ?", filename)
55
+ if row && row[1].to_i == stats.size && row[2] == file_time
56
+ puts "[Database::Mysql] skipping #{filename}" if @configuration["debug"]
57
+ else
58
+ File.open(filename, "r") do |logfile|
59
+ yield "#{File.basename(filename)}#{random_filename_string}.gpg", logfile
60
+ end
61
+
62
+ if row
63
+ @db.execute("UPDATE files SET file_size = ?, file_time = ? WHERE id = ?", stats.size, file_time, row[0])
64
+ else
65
+ stmt = @db.prepare("INSERT INTO files (filename, file_size, file_time) VALUES (?, ?, ?)")
66
+ stmt.execute(filename, stats.size, file_time)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ class Postgresql < Base
74
+ def initialize(configuration)
75
+ super
76
+ require 'archive/tar/minitar'
77
+ end
78
+
79
+ def full_backup
80
+ require 'pg'
81
+
82
+ connect_and_execute "SELECT pg_start_backup('yob')"
83
+
84
+ begin
85
+ writer = yield Time.now.strftime("%Y%m%d-%H%M%S#{random_filename_string}.tar.gpg"), nil
86
+ tar = Archive::Tar::Minitar::Output.new(writer)
87
+ begin
88
+ Find.find(configuration.postgresql_data_directory) do |entry|
89
+ Archive::Tar::Minitar.pack_file(entry, tar) unless entry.include?("/pg_xlog/")
90
+ end
91
+ ensure
92
+ tar.close
93
+ end
94
+ writer.close
95
+ ensure
96
+ connect_and_execute "SELECT pg_stop_backup()"
97
+ end
98
+ end
99
+
100
+ def partial_backup
101
+ raise "yob partial must be called with two more arguments - the full path and the destination filename - for a postgresql partial backup" unless ARGV.length == 3
102
+ full_path = ARGV[1]
103
+ destination_filename = ARGV[2]
104
+
105
+ File.open(full_path, "r") do |file|
106
+ yield "#{destination_filename}#{random_filename_string}", file
107
+ end
108
+ end
109
+
110
+ protected
111
+ def connect_and_execute(sql)
112
+ hostname = @configuration.fetch("postgresql_hostname", "localhost")
113
+ port = @configuration.fetch("postgresql_port", 5432)
114
+
115
+ connection = PGconn.connect(
116
+ hostname, # nil if UNIX socket
117
+ port, # nil if UNIX socket
118
+ '', # options
119
+ '', # unused by library
120
+ @configuration.fetch("postgresql_default_database", "postgres"),
121
+ @configuration["postgresql_username"],
122
+ @configuration["postgresql_password"])
123
+
124
+ connection.exec(sql)
125
+ connection.close
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,36 @@
1
+ module Yob::Encrypt
2
+ class GnuPG
3
+ attr_reader :pid
4
+
5
+ def initialize(configuration)
6
+ require 'gpgme'
7
+ @keys = configuration.encryption_key_names
8
+ end
9
+
10
+ def encryption_input_pipe(output_pipe)
11
+ rd, wr = IO.pipe
12
+ @pid = fork do
13
+ $0 = "yob: GnuPG encryption"
14
+ wr.close
15
+ encrypt(rd, output_pipe)
16
+ end
17
+ rd.close
18
+ output_pipe.close # the fork takes ownership of this descriptor
19
+ wr
20
+ end
21
+
22
+ def encrypt(rd, wr)
23
+ puts "[Encrypt::GnuPG] encrypting input"
24
+
25
+ plain_data = GPGME::Data.new(rd)
26
+ cipher_data = GPGME::Data.new(wr)
27
+ keys = GPGME::Key.find(:public, @keys)
28
+
29
+ GPGME::Ctx.new do |ctx|
30
+ ctx.encrypt(keys, plain_data, cipher_data, GPGME::ENCRYPT_ALWAYS_TRUST)
31
+ end
32
+
33
+ puts "[Encrypt::GnuPG] encrypting complete"
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,79 @@
1
+ module Yob::Store
2
+ class AWS
3
+ BLOCK_SIZE = 1048576 * 5 # must be at least 5 MB
4
+
5
+ attr_reader :pid
6
+
7
+ def initialize(configuration)
8
+ require 'aws-sdk'
9
+
10
+ @configuration = configuration
11
+
12
+ ::AWS.config(:access_key_id => configuration.aws_access_key_id,
13
+ :secret_access_key => configuration.aws_secret_access_key)
14
+ end
15
+
16
+ def storage_input_pipe(filename)
17
+ rd, wr = IO.pipe
18
+ @pid = fork do
19
+ $0 = "yob: AWS S3 storage"
20
+ wr.close
21
+ store(filename, rd)
22
+ rd.close
23
+ end
24
+ rd.close
25
+ wr
26
+ end
27
+
28
+ def store(filename, file_handle)
29
+ puts "[Store::AWS] uploading #{filename}"
30
+ object = s3_bucket.objects["#{@configuration["aws_filename_prefix"]}#{filename}"]
31
+
32
+ data = file_handle.read(BLOCK_SIZE)
33
+ if data.nil? || data.length == 0
34
+ print "[Store::AWS] no file data received, upload aborted"
35
+ return
36
+ end
37
+
38
+ # If the entire file is less than BLOCK_SIZE, send it in one hit.
39
+ # Otherwise use AWS's multipart upload feature. Note that each part must be at least 5MB.
40
+ if data.length < BLOCK_SIZE
41
+ object.write(data)
42
+ else
43
+ upload = object.multipart_uploads.create
44
+ begin
45
+ print "[Store::AWS] multipart upload started\n" if @configuration["debug"]
46
+ bytes = 0
47
+ while data && data.length > 0
48
+ upload.add_part(data)
49
+ bytes += data.length
50
+ print "[Store::AWS] #{bytes} bytes sent\n" if @configuration["debug"]
51
+ data = file_handle.read(BLOCK_SIZE)
52
+ end
53
+ upload.close
54
+ rescue
55
+ upload.abort
56
+ raise
57
+ end
58
+ end
59
+
60
+ if grant_to = @configuration["aws_grant_access_to"]
61
+ puts "[Store::AWS] granting access to #{filename}"
62
+ object.acl = access_control_list(grant_to)
63
+ end
64
+
65
+ puts "[Store::AWS] uploaded"
66
+ end
67
+
68
+ protected
69
+ def s3_bucket
70
+ ::AWS::S3.new.buckets[@configuration.aws_bucket]
71
+ end
72
+
73
+ def access_control_list(email)
74
+ acl = ::AWS::S3::AccessControlList.new
75
+ acl.grant(:full_control).to(:amazon_customer_email => email)
76
+ acl
77
+ end
78
+ end
79
+ end
metadata ADDED
@@ -0,0 +1,116 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: yob
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Roger Nesbitt
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-07-03 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: sqlite3
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: minitar
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: aws-sdk
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ - !ruby/object:Gem::Dependency
63
+ name: gpgme
64
+ requirement: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ! '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ type: :runtime
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ description: YouDo Online Backup
79
+ email: roger@youdo.co.nz
80
+ executables:
81
+ - yob
82
+ extensions: []
83
+ extra_rdoc_files: []
84
+ files:
85
+ - README.rdoc
86
+ - lib/yob.rb
87
+ - bin/yob
88
+ - lib/yob/configuration.rb
89
+ - lib/yob/database.rb
90
+ - lib/yob/encrypt.rb
91
+ - lib/yob/store.rb
92
+ homepage:
93
+ licenses: []
94
+ post_install_message:
95
+ rdoc_options: []
96
+ require_paths:
97
+ - lib
98
+ required_ruby_version: !ruby/object:Gem::Requirement
99
+ none: false
100
+ requirements:
101
+ - - ! '>='
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ required_rubygems_version: !ruby/object:Gem::Requirement
105
+ none: false
106
+ requirements:
107
+ - - ! '>='
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ requirements: []
111
+ rubyforge_project:
112
+ rubygems_version: 1.8.23
113
+ signing_key:
114
+ specification_version: 3
115
+ summary: YouDo Online Backup
116
+ test_files: []