colin-safe 0.1.6

Sign up to get free protection for your applications and to get access to all the features.
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.
data/README.markdown ADDED
@@ -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.
data/Rakefile ADDED
@@ -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
+
data/VERSION.yml ADDED
@@ -0,0 +1,4 @@
1
+ ---
2
+ :major: 0
3
+ :minor: 1
4
+ :patch: 6
data/bin/astrails-safe ADDED
@@ -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,31 @@
1
+ module Astrails
2
+ module Safe
3
+ class Mysqldump < Source
4
+
5
+ def command
6
+ @commanbd ||= "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
+ end
30
+ end
31
+ 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,88 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: colin-safe
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.6
5
+ platform: ruby
6
+ authors:
7
+ - Astrails Ltd.
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-04-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
+ - LICENSE
33
+ - README.markdown
34
+ files:
35
+ - LICENSE
36
+ - README.markdown
37
+ - Rakefile
38
+ - VERSION.yml
39
+ - bin/astrails-safe
40
+ - examples/example_helper.rb
41
+ - examples/unit/config_example.rb
42
+ - examples/unit/stream_example.rb
43
+ - lib/astrails/safe.rb
44
+ - lib/astrails/safe/archive.rb
45
+ - lib/astrails/safe/config/builder.rb
46
+ - lib/astrails/safe/config/node.rb
47
+ - lib/astrails/safe/gpg.rb
48
+ - lib/astrails/safe/gzip.rb
49
+ - lib/astrails/safe/local.rb
50
+ - lib/astrails/safe/mysqldump.rb
51
+ - lib/astrails/safe/pipe.rb
52
+ - lib/astrails/safe/s3.rb
53
+ - lib/astrails/safe/sink.rb
54
+ - lib/astrails/safe/source.rb
55
+ - lib/astrails/safe/stream.rb
56
+ - lib/astrails/safe/tmp_file.rb
57
+ - lib/extensions/mktmpdir.rb
58
+ - templates/script.rb
59
+ has_rdoc: true
60
+ homepage: http://github.com/astrails/safe
61
+ post_install_message:
62
+ rdoc_options:
63
+ - --charset=UTF-8
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: "0"
71
+ version:
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: "0"
77
+ version:
78
+ requirements: []
79
+
80
+ rubyforge_project:
81
+ rubygems_version: 1.2.0
82
+ signing_key:
83
+ specification_version: 2
84
+ summary: Backup filesystem and MySQL to Amazon S3 (with encryption)
85
+ test_files:
86
+ - examples/example_helper.rb
87
+ - examples/unit/stream_example.rb
88
+ - examples/unit/config_example.rb