yob 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []