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.
- data/README.rdoc +27 -0
- data/bin/yob +21 -0
- data/lib/yob.rb +29 -0
- data/lib/yob/configuration.rb +66 -0
- data/lib/yob/database.rb +128 -0
- data/lib/yob/encrypt.rb +36 -0
- data/lib/yob/store.rb +79 -0
- metadata +116 -0
data/README.rdoc
ADDED
@@ -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
|
data/lib/yob.rb
ADDED
@@ -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
|
data/lib/yob/database.rb
ADDED
@@ -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
|
data/lib/yob/encrypt.rb
ADDED
@@ -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
|
data/lib/yob/store.rb
ADDED
@@ -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: []
|