ralph-safe 0.1.7

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/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Astrails Ltd.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,160 @@
1
+ astrails-safe
2
+ =============
3
+
4
+ Simple mysql and filesystem backups with S3 support (with optional encryption)
5
+
6
+ Motivation
7
+ ----------
8
+
9
+ We needed a backup solution that will satisfy the following requirements:
10
+
11
+ * opensource
12
+ * simple to install and configure
13
+ * support for simple ‘tar’ backups of directories (with includes/excludes)
14
+ * support for simple mysqldump of mysql databases
15
+ * support for symmetric or public key encryption
16
+ * support for local filesystem and Amazon S3 for storage
17
+ * support for backup rotation. we don’t want backups filling all the diskspace or cost a fortune on S3
18
+
19
+ And since we didn't find any, we wrote our own :)
20
+
21
+ Usage
22
+ -----
23
+
24
+ Usage:
25
+ astrails-safe [OPTIONS] CONFIG_FILE
26
+ Options:
27
+ -h, --help This help screen
28
+ -v, --verbose be verbose, duh!
29
+ -n, --dry-run just pretend, don't do anything.
30
+ -L, --local skip S3
31
+
32
+ Note: CONFIG_FILE will be created from template if missing
33
+
34
+ Encryption
35
+ ----------
36
+
37
+ If you want to encrypt your backups you have 2 options:
38
+ * use simple password encryption
39
+ * use GPG public key encryption
40
+
41
+ For simple password, just add password entry in gpg section.
42
+ For public key encryption you will need to create a public/secret keypair.
43
+
44
+ We recommend to create your GPG keys only on your local machine and then
45
+ transfer your public key to the server that will do the backups.
46
+
47
+ This way the server will only know how to encrypt the backups but only you
48
+ will be able to decrypt them using the secret key you have locally. Of course
49
+ you MUST backup your backup encryption key :)
50
+ We recommend also pringing the hard paper copy of your GPG key 'just in case'.
51
+
52
+ The procedure to create and transfer the key is as follows:
53
+
54
+ 1. run 'gpg --gen-gen' on your local machine and follow onscreen instructions to create the key
55
+ (you can accept all the defaults).
56
+
57
+ 2. extract your public key into a file (assuming you used test@example.com as your key email):
58
+ gpg -a --export test@example.com > test@example.com.pub
59
+
60
+ 3. transfer public key to the server
61
+ scp backup@example.com root@example.com:
62
+
63
+ 4. import public key on the remote system:
64
+ <pre>
65
+ $ gpg --import test@example.com.pub
66
+ gpg: key 45CA9403: public key "Test Backup <test@example.com>" imported
67
+ gpg: Total number processed: 1
68
+ gpg: imported: 1
69
+ </pre>
70
+
71
+ 5. since we don't keep the secret part of the key on the remote server, gpg has
72
+ no way to know its yours and can be trusted.
73
+ To fix that we can sign it with other trusted key, or just directly modify its
74
+ trust level in gpg (use level 5):
75
+ <pre>
76
+ $ gpg --edit-key test@example.com
77
+ ...
78
+ Command> trust
79
+ ...
80
+ 1 = I don't know or won't say
81
+ 2 = I do NOT trust
82
+ 3 = I trust marginally
83
+ 4 = I trust fully
84
+ 5 = I trust ultimately
85
+ m = back to the main menu
86
+
87
+ Your decision? 5
88
+ ...
89
+ Command> quit
90
+ </pre>
91
+
92
+ 6. export your secret key for backup
93
+ (we recommend to print it on paper and burn to a CD/DVD and store in a safe place):
94
+ <pre>
95
+ $ gpg -a --export-secret-key test@example.com > test@example.com.key
96
+ </pre>
97
+
98
+
99
+ Example configuration
100
+ ---------------------
101
+ <pre>
102
+ safe do
103
+ local :path => "/backup/:kind/:id"
104
+
105
+ s3 do
106
+ key "...................."
107
+ secret "........................................"
108
+ bucket "backup.astrails.com"
109
+ path "servers/alpha/:kind/:id"
110
+ end
111
+
112
+ gpg do
113
+ # symmetric encryption key
114
+ # password "qwe"
115
+
116
+ # public GPG key (must be known to GPG, i.e. be on the keyring)
117
+ key "backup@astrails.com"
118
+ end
119
+
120
+ keep do
121
+ local 2
122
+ s3 2
123
+ end
124
+
125
+ mysqldump do
126
+ options "-ceKq --single-transaction --create-options"
127
+
128
+ user "root"
129
+ password "............"
130
+ socket "/var/run/mysqld/mysqld.sock"
131
+
132
+ database :blog
133
+ database :servershape
134
+ database :astrails_com
135
+ database :secret_project_com
136
+
137
+ end
138
+
139
+ tar do
140
+ archive "git-repositories", :files => "/home/git/repositories"
141
+ archive "dot-configs", :files => "/home/*/.[^.]*"
142
+ archive "etc", :files => "/etc", :exclude => "/etc/puppet/other"
143
+
144
+ archive "blog-astrails-com" do
145
+ files "/var/www/blog.astrails.com/"
146
+ exclude ["/var/www/blog.astrails.com/log", "/var/www/blog.astrails.com/tmp"]
147
+ end
148
+
149
+ archive "astrails-com" do
150
+ files "/var/www/astrails.com/"
151
+ exclude ["/var/www/astrails.com/log", "/var/www/astrails.com/tmp"]
152
+ end
153
+ end
154
+ end
155
+ </pre>
156
+
157
+ Copyright
158
+ ---------
159
+
160
+ Copyright (c) 2009 Astrails Ltd. See LICENSE for details.
@@ -0,0 +1,52 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "safe"
8
+ gem.summary = %Q{Backup filesystem and MySQL to Amazon S3 (with encryption)}
9
+ gem.description = "Simple tool to backup MySQL databases and filesystem locally or to Amazon S3 (with optional encryption)"
10
+ gem.email = "we@astrails.com"
11
+ gem.homepage = "http://github.com/astrails/safe"
12
+ gem.authors = ["Astrails Ltd."]
13
+ gem.files = FileList["[A-Z]*.*", "{bin,examples,generators,lib,rails,spec,test,templates}/**/*", 'Rakefile', 'LICENSE*']
14
+
15
+ gem.add_dependency("aws-s3")
16
+
17
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
18
+ end
19
+ rescue LoadError
20
+ puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
21
+ end
22
+
23
+ require 'micronaut/rake_task'
24
+ Micronaut::RakeTask.new(:examples) do |examples|
25
+ examples.pattern = 'examples/**/*_example.rb'
26
+ examples.ruby_opts << '-Ilib -Iexamples'
27
+ end
28
+
29
+ Micronaut::RakeTask.new(:rcov) do |examples|
30
+ examples.pattern = 'examples/**/*_example.rb'
31
+ examples.rcov_opts = '-Ilib -Iexamples'
32
+ examples.rcov = true
33
+ end
34
+
35
+
36
+ task :default => :examples
37
+
38
+ require 'rake/rdoctask'
39
+ Rake::RDocTask.new do |rdoc|
40
+ if File.exist?('VERSION.yml')
41
+ config = YAML.load(File.read('VERSION.yml'))
42
+ version = "#{config[:major]}.#{config[:minor]}.#{config[:patch]}"
43
+ else
44
+ version = ""
45
+ end
46
+
47
+ rdoc.rdoc_dir = 'rdoc'
48
+ rdoc.title = "safe #{version}"
49
+ rdoc.rdoc_files.include('README*')
50
+ rdoc.rdoc_files.include('lib/**/*.rb')
51
+ end
52
+
@@ -0,0 +1,4 @@
1
+ ---
2
+ :major: 0
3
+ :minor: 1
4
+ :patch: 7
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env ruby
2
+
3
+
4
+ require 'tempfile'
5
+ require 'rubygems'
6
+ require 'fileutils'
7
+ require "aws/s3"
8
+ require 'yaml'
9
+
10
+ #require 'ruby-debug'
11
+ #$:.unshift File.join(File.dirname(__FILE__), "..", "lib")
12
+
13
+ require 'astrails/safe'
14
+ include Astrails::Safe
15
+
16
+ def die(msg)
17
+ puts "ERROR: #{msg}"
18
+ exit 1
19
+ end
20
+
21
+ def usage
22
+ puts <<-END
23
+ Usage: astrails-safe [OPTIONS] CONFIG_FILE
24
+ Options:
25
+ -h, --help This help screen
26
+ -v, --verbose be verbose, duh!
27
+ -n, --dry-run just pretend, don't do anything.
28
+ -L, --local skip S3
29
+
30
+ Note: config file will be created from template if missing
31
+ END
32
+ exit 1
33
+ end
34
+
35
+ def process_options
36
+ usage if ARGV.delete("-h") || ARGV.delete("--help")
37
+ $_VERBOSE = ARGV.delete("-v") || ARGV.delete("--verbose")
38
+ $DRY_RUN = ARGV.delete("-n") || ARGV.delete("--dry-run")
39
+ $LOCAL = ARGV.delete("-L") || ARGV.delete("--local")
40
+ usage unless ARGV.first
41
+ $CONFIG_FILE_NAME = File.expand_path(ARGV.first)
42
+ end
43
+
44
+ def main
45
+ process_options
46
+
47
+ unless File.exists?($CONFIG_FILE_NAME)
48
+ die "Missing configuration file. NOT CREATED! Rerun w/o the -n argument to create a template configuration file." if $DRY_RUN
49
+
50
+ FileUtils.cp File.join(Astrails::Safe::ROOT, "templates", "script.rb"), $CONFIG_FILE_NAME
51
+
52
+ die "Created default #{$CONFIG_FILE_NAME}. Please edit and run again."
53
+ end
54
+
55
+
56
+ load($CONFIG_FILE_NAME)
57
+ end
58
+
59
+ main
@@ -0,0 +1,19 @@
1
+ require 'rubygems'
2
+ require 'micronaut'
3
+ require 'ruby-debug'
4
+
5
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
6
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
7
+
8
+ require 'astrails/safe'
9
+
10
+ def not_in_editor?
11
+ !(ENV.has_key?('TM_MODE') || ENV.has_key?('EMACS') || ENV.has_key?('VIM'))
12
+ end
13
+
14
+ Micronaut.configure do |c|
15
+ c.color_enabled = not_in_editor?
16
+ c.filter_run :focused => true
17
+ c.mock_with :rr
18
+ end
19
+
@@ -0,0 +1,126 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../example_helper')
2
+
3
+ describe Astrails::Safe::Config do
4
+ it "foo" do
5
+ config = Astrails::Safe::Config::Node.new do
6
+ local do
7
+ path "path"
8
+ end
9
+
10
+ s3 do
11
+ key "s3 key"
12
+ secret "secret"
13
+ bucket "bucket"
14
+ path "path1"
15
+ end
16
+
17
+ gpg do
18
+ key "gpg-key"
19
+ password "astrails"
20
+ end
21
+
22
+ keep do
23
+ local 4
24
+ s3 20
25
+ end
26
+
27
+ mysqldump do
28
+ options "-ceKq --single-transaction --create-options"
29
+
30
+ user "astrails"
31
+ password ""
32
+ host "localhost"
33
+ port 3306
34
+ socket "/var/run/mysqld/mysqld.sock"
35
+
36
+ database :blog
37
+
38
+ database :production do
39
+ keep :local => 3
40
+
41
+ gpg do
42
+ password "custom-production-pass"
43
+ end
44
+
45
+ skip_tables [:logger_exceptions, :request_logs]
46
+ end
47
+
48
+ end
49
+
50
+
51
+ tar do
52
+ archive "git-repositories" do
53
+ files "/home/git/repositories"
54
+ end
55
+
56
+ archive "etc-files" do
57
+ files "/etc"
58
+ exclude "/etc/puppet/other"
59
+ end
60
+
61
+ archive "dot-configs" do
62
+ files "/home/*/.[^.]*"
63
+ end
64
+
65
+ archive "blog" do
66
+ files "/var/www/blog.astrails.com/"
67
+ exclude ["/var/www/blog.astrails.com/log", "/var/www/blog.astrails.com/tmp"]
68
+ end
69
+
70
+ archive :misc do
71
+ files [ "/backup/*.rb" ]
72
+ end
73
+ end
74
+
75
+ end
76
+
77
+ expected = {
78
+ "local" => {"path" => "path"},
79
+
80
+ "s3" => {
81
+ "key" => "s3 key",
82
+ "secret" => "secret",
83
+ "bucket" => "bucket",
84
+ "path" => "path1",
85
+ },
86
+
87
+ "gpg" => {"password" => "astrails", "key" => "gpg-key"},
88
+
89
+ "keep" => {"s3" => 20, "local" => 4},
90
+
91
+ "mysqldump" => {
92
+ "options" => "-ceKq --single-transaction --create-options",
93
+ "user" => "astrails",
94
+ "password" => "",
95
+ "host" => "localhost",
96
+ "port" => 3306,
97
+ "socket" => "/var/run/mysqld/mysqld.sock",
98
+
99
+ "databases" => {
100
+ "blog" => {},
101
+ "production" => {
102
+ "keep" => {"local" => 3},
103
+ "gpg" => {"password" => "custom-production-pass"},
104
+ "skip_tables" => ["logger_exceptions", "request_logs"],
105
+ },
106
+ },
107
+ },
108
+ "tar" => {
109
+ "archives" => {
110
+ "git-repositories" => {"files" => "/home/git/repositories"},
111
+ "etc-files" => {"files" => "/etc", "exclude" => "/etc/puppet/other"},
112
+ "dot-configs" => {"files" => "/home/*/.[^.]*"},
113
+ "blog" => {
114
+ "files" => "/var/www/blog.astrails.com/",
115
+ "exclude" => ["/var/www/blog.astrails.com/log", "/var/www/blog.astrails.com/tmp"],
116
+ },
117
+ "misc" => { "files" => ["/backup/*.rb"] },
118
+ },
119
+ },
120
+ }
121
+
122
+ config.to_hash.should == expected
123
+
124
+ end
125
+ end
126
+
@@ -0,0 +1,33 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../example_helper')
2
+
3
+ describe Astrails::Safe::Stream do
4
+
5
+ before(:each) do
6
+ @parent = Object.new
7
+ @stream = Astrails::Safe::Stream.new(@parent)
8
+ @r = rand(10)
9
+ end
10
+
11
+ def self.it_delegates_to_parent(prop)
12
+ it "delegates #{prop} to parent if not set" do
13
+ mock(@parent).__send__(prop) {@r}
14
+ @stream.send(prop).should == @r
15
+ end
16
+ end
17
+
18
+ def self.it_delegates_to_parent_with_cache(prop)
19
+ it_delegates_to_parent(prop)
20
+
21
+ it "uses cached value for #{prop}" do
22
+ dont_allow(@parent).__send__(prop)
23
+ @stream.instance_variable_set "@#{prop}", @r + 1
24
+ @stream.send(prop).should == @r + 1
25
+ end
26
+ end
27
+
28
+ it_delegates_to_parent_with_cache :id
29
+ it_delegates_to_parent_with_cache :config
30
+
31
+ it_delegates_to_parent :filename
32
+
33
+ end
@@ -0,0 +1,41 @@
1
+ require 'extensions/mktmpdir'
2
+ require 'astrails/safe/tmp_file'
3
+
4
+ require 'astrails/safe/config/node'
5
+ require 'astrails/safe/config/builder'
6
+
7
+ require 'astrails/safe/stream'
8
+
9
+ require 'astrails/safe/source'
10
+ require 'astrails/safe/mysqldump'
11
+ require 'astrails/safe/archive'
12
+
13
+ require 'astrails/safe/pipe'
14
+ require 'astrails/safe/gpg'
15
+ require 'astrails/safe/gzip'
16
+
17
+ require 'astrails/safe/sink'
18
+ require 'astrails/safe/local'
19
+ require 'astrails/safe/s3'
20
+
21
+
22
+ module Astrails
23
+ module Safe
24
+ ROOT = File.join(File.dirname(__FILE__), "..", "..")
25
+
26
+ def timestamp
27
+ @timestamp ||= Time.now.strftime("%y%m%d-%H%M")
28
+ end
29
+
30
+ def safe(&block)
31
+ config = Config::Node.new(&block)
32
+ #config.dump
33
+
34
+ Astrails::Safe::Mysqldump.run(config[:mysqldump, :databases])
35
+ Astrails::Safe::Archive.run(config[:tar, :archives])
36
+
37
+ Astrails::Safe::TmpFile.cleanup
38
+ end
39
+ end
40
+ end
41
+
@@ -0,0 +1,24 @@
1
+ module Astrails
2
+ module Safe
3
+ class Archive < Source
4
+
5
+ def command
6
+ "tar -cf - #{@config[:options]} #{tar_exclude_files} #{tar_files}"
7
+ end
8
+
9
+ def extension; '.tar'; end
10
+
11
+ protected
12
+
13
+ def tar_exclude_files
14
+ [*@config[:exclude]].compact.map{|x| "--exclude=#{x}"} * " "
15
+ end
16
+
17
+ def tar_files
18
+ raise RuntimeError, "missing files for tar" unless @config[:files]
19
+ [*@config[:files]] * " "
20
+ end
21
+
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,60 @@
1
+ module Astrails
2
+ module Safe
3
+ module Config
4
+ class Builder
5
+ COLLECTIONS = %w/database archive/
6
+ ITEMS = %w/s3 key secret bucket path gpg password keep local mysqldump options
7
+ user host port socket skip_tables tar files exclude filename/
8
+ NAMES = COLLECTIONS + ITEMS
9
+ def initialize(node)
10
+ @node = node
11
+ end
12
+
13
+ # supported args:
14
+ # args = [value]
15
+ # args = [id, data]
16
+ # args = [data]
17
+ # id/value - simple values, data - hash
18
+ def method_missing(sym, *args, &block)
19
+ return super unless NAMES.include?(sym.to_s)
20
+
21
+ # do we have id or value?
22
+ unless args.first.is_a?(Hash)
23
+ id_or_value = args.shift # nil for args == []
24
+ end
25
+
26
+ id_or_value = id_or_value.map {|v| v.to_s} if id_or_value.is_a?(Array)
27
+
28
+ # do we have data hash?
29
+ if data = args.shift
30
+ die "#{sym}: hash expected: #{data.inspect}" unless data.is_a?(Hash)
31
+ end
32
+
33
+ #puts "#{sym}: args=#{args.inspect}, id_or_value=#{id_or_value}, data=#{data.inspect}, block=#{block.inspect}"
34
+
35
+ die "#{sym}: unexpected: #{args.inspect}" unless args.empty?
36
+ die "#{sym}: missing arguments" unless id_or_value || data || block
37
+
38
+ if COLLECTIONS.include?(sym.to_s) && id_or_value
39
+ data ||= {}
40
+ end
41
+
42
+ if !data && !block
43
+ # simple value assignment
44
+ @node[sym] = id_or_value
45
+
46
+ elsif id_or_value
47
+ # collection element with id => create collection node and a subnode in it
48
+ key = sym.to_s + "s"
49
+ collection = @node[key] || @node.set(key, {})
50
+ collection.set(id_or_value, data || {}, &block)
51
+
52
+ else
53
+ # simple subnode
54
+ @node.set(sym, data || {}, &block)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,67 @@
1
+ require 'astrails/safe/config/builder'
2
+ module Astrails
3
+ module Safe
4
+ module Config
5
+ class Node
6
+ attr_reader :parent
7
+ def initialize(parent = nil, data = {}, &block)
8
+ @parent, @data = parent, {}
9
+ data.each { |k, v| self[k] = v }
10
+ Builder.new(self).instance_eval(&block) if block
11
+ end
12
+
13
+ # looks for the path from this node DOWN. will not delegate to parent
14
+ def get(*path)
15
+ key = path.shift
16
+ value = @data[key.to_s]
17
+ return value if value && path.empty?
18
+
19
+ value && value.get(*path)
20
+ end
21
+
22
+ # recursive find
23
+ # starts at the node and continues to the parent
24
+ def find(*path)
25
+ get(*path) || @parent && @parent.find(*path)
26
+ end
27
+ alias :[] :find
28
+
29
+ def set(key, value, &block)
30
+ @data[key.to_s] =
31
+ if value.is_a?(Hash)
32
+ Node.new(self, value, &block)
33
+ else
34
+ raise(ArgumentError, "#{key}: no block supported for simple values") if block
35
+ value
36
+ end
37
+ end
38
+ alias :[]= :set
39
+
40
+ def each(&block)
41
+ @data.each(&block)
42
+ end
43
+ include Enumerable
44
+
45
+ def to_hash
46
+ @data.keys.inject({}) do |res, key|
47
+ value = @data[key]
48
+ res[key] = value.is_a?(Node) ? value.to_hash : value
49
+ res
50
+ end
51
+ end
52
+
53
+ def dump(indent = "")
54
+ @data.each do |key, value|
55
+ if value.is_a?(Node)
56
+ puts "#{indent}#{key}:"
57
+ value.dump(indent + " ")
58
+ else
59
+ puts "#{indent}#{key}: #{value.inspect}"
60
+ end
61
+ end
62
+ end
63
+
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,42 @@
1
+ module Astrails
2
+ module Safe
3
+ class Gpg < Pipe
4
+
5
+ def compressed?
6
+ active? || @parent.compressed?
7
+ end
8
+
9
+ protected
10
+
11
+ def pipe
12
+ if key
13
+ rise RuntimeError, "can't use both gpg password and pubkey" if password
14
+ "|gpg -e -r #{key}"
15
+ elsif password
16
+ "|gpg -c --passphrase-file #{gpg_password_file(password)}"
17
+ end
18
+ end
19
+
20
+ def extension
21
+ ".gpg" if active?
22
+ end
23
+
24
+ def active?
25
+ password || key
26
+ end
27
+
28
+ def password
29
+ @password ||= config[:gpg, :password]
30
+ end
31
+
32
+ def key
33
+ @key ||= config[:gpg, :key]
34
+ end
35
+
36
+ def gpg_password_file(pass)
37
+ return "TEMP_GENERATED_FILENAME" if $DRY_RUN
38
+ Astrails::Safe::TmpFile.create("gpg-pass") { |file| file.write(pass) }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,25 @@
1
+ module Astrails
2
+ module Safe
3
+ class Gzip < Pipe
4
+
5
+ def compressed?
6
+ true
7
+ end
8
+
9
+ protected
10
+
11
+ def pipe
12
+ "|gzip" if active?
13
+ end
14
+
15
+ def extension
16
+ ".gz" if active?
17
+ end
18
+
19
+ def active?
20
+ !@parent.compressed?
21
+ end
22
+
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,54 @@
1
+ module Astrails
2
+ module Safe
3
+ class Local < Sink
4
+
5
+ def open(&block)
6
+ return @parent.open(&block) unless active?
7
+ run
8
+ File.open(path, &block) unless $DRY_RUN
9
+ end
10
+
11
+ protected
12
+
13
+ def active?
14
+ # S3 can't upload from pipe. it needs to know file size, so we must pass through :local
15
+ # will change once we add SSH sink
16
+ true
17
+ end
18
+
19
+ def prefix
20
+ @prefix ||= File.expand_path(expand(@config[:local, :path] || raise(RuntimeError, "missing :local/:path in configuration")))
21
+ end
22
+
23
+ def command
24
+ "#{@parent.command} > #{path}"
25
+ end
26
+
27
+ def save
28
+ puts "command: #{command}" if $_VERBOSE
29
+ unless $DRY_RUN
30
+ FileUtils.mkdir_p(prefix) unless File.directory?(prefix)
31
+ system command
32
+ end
33
+ end
34
+
35
+ def cleanup
36
+ return unless keep = @config[:keep, :local]
37
+
38
+ base = File.basename(filename).split(".").first
39
+
40
+ pattern = File.join(prefix, "#{base}*")
41
+ puts "listing files #{pattern.inspect}" if $_VERBOSE
42
+ files = Dir[pattern] .
43
+ select{|f| File.file?(f)} .
44
+ sort
45
+
46
+ cleanup_with_limit(files, keep) do |f|
47
+ puts "removing local file #{f}" if $DRY_RUN || $_VERBOSE
48
+ File.unlink(f) unless $DRY_RUN
49
+ end
50
+ end
51
+
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,34 @@
1
+ module Astrails
2
+ module Safe
3
+ class Mysqldump < Source
4
+
5
+ def command
6
+ @command ||= "#{path}mysqldump --defaults-extra-file=#{mysql_password_file} #{@config[:options]} #{mysql_skip_tables} #{@id}"
7
+ end
8
+
9
+ def extension; '.sql'; end
10
+
11
+ protected
12
+
13
+ def mysql_password_file
14
+ Astrails::Safe::TmpFile.create("mysqldump") do |file|
15
+ file.puts "[mysqldump]"
16
+ %w/user password socket host port/.each do |k|
17
+ v = @config[k]
18
+ file.puts "#{k} = #{v}" if v
19
+ end
20
+ end
21
+ end
22
+
23
+ def mysql_skip_tables
24
+ if skip_tables = @config[:skip_tables]
25
+ [*skip_tables].map { |t| "--ignore-table=#{@id}.#{t}" } * " "
26
+ end
27
+ end
28
+
29
+ def path
30
+ @config[:path] ||= ""
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,15 @@
1
+ module Astrails
2
+ module Safe
3
+ class Pipe < Stream
4
+
5
+ def command
6
+ "#{@parent.command}#{pipe}"
7
+ end
8
+
9
+ def filename
10
+ "#{@parent.filename}#{extension}"
11
+ end
12
+
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,68 @@
1
+ module Astrails
2
+ module Safe
3
+ class S3 < Sink
4
+
5
+ protected
6
+
7
+ def active?
8
+ bucket && key && secret
9
+ end
10
+
11
+ def prefix
12
+ @prefix ||= expand(config[:s3, :path] || expand(config[:local, :path] || ":kind/:id"))
13
+ end
14
+
15
+ def save
16
+ # needed in cleanup even on dry run
17
+ AWS::S3::Base.establish_connection!(:access_key_id => key, :secret_access_key => secret, :use_ssl => true) unless $LOCAL
18
+
19
+ file = @parent.open
20
+ puts "Uploading #{bucket}:#{path}" if $_VERBOSE || $DRY_RUN
21
+ unless $DRY_RUN || $LOCAL
22
+ AWS::S3::Bucket.create(bucket)
23
+ AWS::S3::S3Object.store(path, file, bucket)
24
+ puts "...done" if $_VERBOSE
25
+ end
26
+ file.close if file
27
+
28
+ end
29
+
30
+ def cleanup
31
+
32
+ return if $LOCAL
33
+
34
+ return unless keep = @config[:keep, :s3]
35
+
36
+ bucket = @config[:s3, :bucket]
37
+
38
+ base = File.basename(filename).split(".").first
39
+
40
+ puts "listing files in #{bucket}:#{prefix}/#{base}"
41
+ files = AWS::S3::Bucket.objects(bucket, :prefix => "#{prefix}/#{base}", :max_keys => keep * 2)
42
+ puts files.collect {|x| x.key} if $_VERBOSE
43
+
44
+ files = files.
45
+ collect {|x| x.key}.
46
+ sort
47
+
48
+ cleanup_with_limit(files, keep) do |f|
49
+ puts "removing s3 file #{bucket}:#{f}" if $DRY_RUN || $_VERBOSE
50
+ AWS::S3::Bucket.find(bucket)[f].delete unless $DRY_RUN || $LOCAL
51
+ end
52
+ end
53
+
54
+ def bucket
55
+ config[:s3, :bucket]
56
+ end
57
+
58
+ def key
59
+ config[:s3, :key]
60
+ end
61
+
62
+ def secret
63
+ config[:s3, :secret]
64
+ end
65
+
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,33 @@
1
+ module Astrails
2
+ module Safe
3
+ class Sink < Stream
4
+
5
+ def run
6
+ if active?
7
+ save
8
+ cleanup
9
+ else
10
+ @parent.run
11
+ end
12
+ end
13
+
14
+ protected
15
+
16
+ # prefix is defined in subclass
17
+ def path
18
+ @path ||= File.join(prefix, filename)
19
+ end
20
+
21
+ # call block on files to be removed (all except for the LAST 'limit' files
22
+ def cleanup_with_limit(files, limit, &block)
23
+ return unless files.size > limit
24
+
25
+ to_remove = files[0..(files.size - limit - 1)]
26
+ # TODO: validate here
27
+ to_remove.each(&block)
28
+ end
29
+
30
+ end
31
+ end
32
+ end
33
+
@@ -0,0 +1,31 @@
1
+ module Astrails
2
+ module Safe
3
+ class Source < Stream
4
+
5
+ def initialize(id, config)
6
+ @id, @config = id, config
7
+ end
8
+
9
+ def filename
10
+ @filename ||= expand(":kind-:id.:timestamp#{extension}")
11
+ end
12
+
13
+ # process each config key as source (with full pipe)
14
+ def self.run(config)
15
+ unless config
16
+ puts "No configuration found for #{human_name}"
17
+ return
18
+ end
19
+
20
+ config.each do |key, value|
21
+ stream = [Gpg, Gzip, Local, S3].inject(new(key, value)) do |res, klass|
22
+ klass.new(res)
23
+ end
24
+ stream.run
25
+ end
26
+ end
27
+
28
+ end
29
+ end
30
+ end
31
+
@@ -0,0 +1,45 @@
1
+ module Astrails
2
+ module Safe
3
+ class Stream
4
+
5
+ def initialize(parent)
6
+ @parent = parent
7
+ end
8
+
9
+ def id
10
+ @id ||= @parent.id
11
+ end
12
+
13
+ def config
14
+ @config ||= @parent.config
15
+ end
16
+
17
+ def filename
18
+ @parent.filename
19
+ end
20
+
21
+ def compressed?
22
+ @parent && @parent.compressed?
23
+ end
24
+
25
+ protected
26
+
27
+ def self.human_name
28
+ name.split('::').last.downcase
29
+ end
30
+
31
+ def kind
32
+ @parent ? @parent.kind : self.class.human_name
33
+ end
34
+
35
+ def expand(path)
36
+ path .
37
+ gsub(/:kind\b/, kind) .
38
+ gsub(/:id\b/, id) .
39
+ gsub(/:timestamp\b/, timestamp)
40
+ end
41
+
42
+ end
43
+ end
44
+ end
45
+
@@ -0,0 +1,25 @@
1
+ require 'tmpdir'
2
+ module Astrails
3
+ module Safe
4
+ module TmpFile
5
+ @KEEP_FILES = []
6
+ TMPDIR = Dir.mktmpdir
7
+
8
+ def self.cleanup
9
+ FileUtils.remove_entry_secure TMPDIR
10
+ end
11
+
12
+ def self.create(name)
13
+ # create temp directory
14
+
15
+ file = Tempfile.new(name, TMPDIR)
16
+
17
+ yield file
18
+
19
+ file.close
20
+ @KEEP_FILES << file # so that it will not get gcollected and removed from filesystem until the end
21
+ file.path
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,45 @@
1
+ require 'tmpdir'
2
+
3
+ unless Dir.respond_to?(:mktmpdir)
4
+ # backward compat for 1.8.6
5
+ class Dir
6
+ def Dir.mktmpdir(prefix_suffix=nil, tmpdir=nil)
7
+ case prefix_suffix
8
+ when nil
9
+ prefix = "d"
10
+ suffix = ""
11
+ when String
12
+ prefix = prefix_suffix
13
+ suffix = ""
14
+ when Array
15
+ prefix = prefix_suffix[0]
16
+ suffix = prefix_suffix[1]
17
+ else
18
+ raise ArgumentError, "unexpected prefix_suffix: #{prefix_suffix.inspect}"
19
+ end
20
+ tmpdir ||= Dir.tmpdir
21
+ t = Time.now.strftime("%Y%m%d")
22
+ n = nil
23
+ begin
24
+ path = "#{tmpdir}/#{prefix}#{t}-#{$$}-#{rand(0x100000000).to_s(36)}"
25
+ path << "-#{n}" if n
26
+ path << suffix
27
+ Dir.mkdir(path, 0700)
28
+ rescue Errno::EEXIST
29
+ n ||= 0
30
+ n += 1
31
+ retry
32
+ end
33
+
34
+ if block_given?
35
+ begin
36
+ yield path
37
+ ensure
38
+ FileUtils.remove_entry_secure path
39
+ end
40
+ else
41
+ path
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,109 @@
1
+ safe do
2
+
3
+ # backup file path (not including filename)
4
+ # supported substitutions:
5
+ # :kind -> backup 'engine' kind, e.g. "mysqldump" or "archive"
6
+ # :id -> backup 'id', e.g. "blog", "production", etc.
7
+ # :timestamp -> current run timestamp (same for all the backups in the same 'run')
8
+ # you can set separate :path for all backups (or once globally here)
9
+ local do
10
+ path "/backup/:kind/"
11
+ end
12
+
13
+ ## uncomment to enable uploads to Amazon S3
14
+ ## Amazon S3 auth (optional)
15
+ ## don't forget to add :s3 to the 'store' list
16
+ # s3 do
17
+ # key YOUR_S3_KEY
18
+ # secret YOUR_S3_SECRET
19
+ # bucket S3_BUCKET
20
+ # # path for uploads to S3. supports same substitution like :local/:path
21
+ # path ":kind/" # this is default
22
+ # end
23
+
24
+ ## alternative style:
25
+ # s3 :key => YOUR_S3_KEY, :secret => YOUR_S3_SECRET, :bucket => S3_BUCKET
26
+
27
+ ## uncomment to enable GPG encryption.
28
+ ## Note: you can use public 'key' or symmetric password but not both!
29
+ # gpg do
30
+ # # key "backup@astrails.com"
31
+ # password "astrails"
32
+ # end
33
+
34
+ ## uncomment to enable backup rotation. keep only given number of latest
35
+ ## backups. remove the rest
36
+ # keep do
37
+ # local 4 # keep 4 local backups
38
+ # s3 20 # keep 20 S3 backups
39
+ # end
40
+
41
+ # backup mysql databases with mysqldump
42
+ mysqldump do
43
+ # you can override any setting from parent in a child:
44
+ options "-ceKq --single-transaction --create-options"
45
+
46
+ user "astrails"
47
+ password ""
48
+ # host "localhost"
49
+ # port 3306
50
+ socket "/var/run/mysqld/mysqld.sock"
51
+
52
+ # database is a 'collection' element. it must have a hash or block parameter
53
+ # it will be 'collected' in a 'databases', with database id (1st arg) used as hash key
54
+ # the following code will create mysqldump/databases/blog and mysqldump/databases/mysql ocnfiguration 'nodes'
55
+
56
+ # backup database with default values
57
+ # database :blog
58
+
59
+ # backup overriding some values
60
+ # database :production do
61
+ # # you can override 'partially'
62
+ # keep :local => 3
63
+ # # keep/local is 3, and keep/s3 is 20 (from parent)
64
+
65
+ # # local override for gpg password
66
+ # gpg do
67
+ # password "custom-production-pass"
68
+ # end
69
+
70
+ # skip_tables [:logger_exceptions, :request_logs] # skip those tables during backup
71
+ # end
72
+
73
+ end
74
+
75
+
76
+ tar do
77
+ # 'archive' is a collection item, just like 'database'
78
+ # archive "git-repositories" do
79
+ # # files and directories to backup
80
+ # files "/home/git/repositories"
81
+ # end
82
+
83
+ # archive "etc-files" do
84
+ # files "/etc"
85
+ # # exlude those files/directories
86
+ # exclude "/etc/puppet/other"
87
+ # end
88
+
89
+ # archive "dot-configs" do
90
+ # files "/home/*/.[^.]*"
91
+ # end
92
+
93
+ # archive "blog" do
94
+ # files "/var/www/blog.astrails.com/"
95
+ # # specify multiple files/directories as array
96
+ # exclude ["/var/www/blog.astrails.com/log", "/var/www/blog.astrails.com/tmp"]
97
+ # end
98
+
99
+ # archive "site" do
100
+ # files "/var/www/astrails.com/"
101
+ # exclude ["/var/www/astrails.com/log", "/var/www/astrails.com/tmp"]
102
+ # end
103
+
104
+ # archive :misc do
105
+ # files [ "/backup/*.rb" ]
106
+ # end
107
+ end
108
+
109
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ralph-safe
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.7
5
+ platform: ruby
6
+ authors:
7
+ - Astrails Ltd.
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-05-16 00:00:00 -07:00
13
+ default_executable: astrails-safe
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: aws-s3
17
+ type: :runtime
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: "0"
24
+ version:
25
+ description: Simple tool to backup MySQL databases and filesystem locally or to Amazon S3 (with optional encryption)
26
+ email: we@astrails.com
27
+ executables:
28
+ - astrails-safe
29
+ extensions: []
30
+
31
+ extra_rdoc_files:
32
+ - README.markdown
33
+ - LICENSE
34
+ files:
35
+ - README.markdown
36
+ - VERSION.yml
37
+ - bin/astrails-safe
38
+ - examples/example_helper.rb
39
+ - examples/unit
40
+ - examples/unit/config_example.rb
41
+ - examples/unit/stream_example.rb
42
+ - lib/astrails
43
+ - lib/astrails/safe
44
+ - lib/astrails/safe/archive.rb
45
+ - lib/astrails/safe/config
46
+ - lib/astrails/safe/config/builder.rb
47
+ - lib/astrails/safe/config/node.rb
48
+ - lib/astrails/safe/gpg.rb
49
+ - lib/astrails/safe/gzip.rb
50
+ - lib/astrails/safe/local.rb
51
+ - lib/astrails/safe/mysqldump.rb
52
+ - lib/astrails/safe/pipe.rb
53
+ - lib/astrails/safe/s3.rb
54
+ - lib/astrails/safe/sink.rb
55
+ - lib/astrails/safe/source.rb
56
+ - lib/astrails/safe/stream.rb
57
+ - lib/astrails/safe/tmp_file.rb
58
+ - lib/astrails/safe.rb
59
+ - lib/extensions
60
+ - lib/extensions/mktmpdir.rb
61
+ - templates/script.rb
62
+ - Rakefile
63
+ - LICENSE
64
+ has_rdoc: true
65
+ homepage: http://github.com/astrails/safe
66
+ post_install_message:
67
+ rdoc_options:
68
+ - --inline-source
69
+ - --charset=UTF-8
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: "0"
77
+ version:
78
+ required_rubygems_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: "0"
83
+ version:
84
+ requirements: []
85
+
86
+ rubyforge_project:
87
+ rubygems_version: 1.2.0
88
+ signing_key:
89
+ specification_version: 2
90
+ summary: Backup filesystem and MySQL to Amazon S3 (with encryption)
91
+ test_files: []
92
+