bibliotech 0.1.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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/bin/bibliotech +5 -0
  3. data/doc/example_config_file.yml +58 -0
  4. data/doc/todo.txt +19 -0
  5. data/lib/bibliotech/application.rb +95 -0
  6. data/lib/bibliotech/backups/file_record.rb +16 -0
  7. data/lib/bibliotech/backups/prune_list.rb +58 -0
  8. data/lib/bibliotech/backups/pruner.rb +71 -0
  9. data/lib/bibliotech/backups/scheduler.rb +49 -0
  10. data/lib/bibliotech/builders/database.rb +25 -0
  11. data/lib/bibliotech/builders/file.rb +75 -0
  12. data/lib/bibliotech/builders/gzip.rb +51 -0
  13. data/lib/bibliotech/builders/mysql.rb +35 -0
  14. data/lib/bibliotech/builders/postgres.rb +37 -0
  15. data/lib/bibliotech/builders.rb +43 -0
  16. data/lib/bibliotech/cli.rb +24 -0
  17. data/lib/bibliotech/command_generator.rb +86 -0
  18. data/lib/bibliotech/command_runner.rb +36 -0
  19. data/lib/bibliotech/compression/bzip2.rb +6 -0
  20. data/lib/bibliotech/compression/gzip.rb +6 -0
  21. data/lib/bibliotech/compression/sevenzip.rb +5 -0
  22. data/lib/bibliotech/compression.rb +35 -0
  23. data/lib/bibliotech/config.rb +269 -0
  24. data/lib/bibliotech/rake_lib.rb +82 -0
  25. data/lib/bibliotech.rb +7 -0
  26. data/spec/bibliotech/backup_pruner_spec.rb +58 -0
  27. data/spec/bibliotech/backup_scheduler_spec.rb +108 -0
  28. data/spec/bibliotech/command_generator/mysql_spec.rb +170 -0
  29. data/spec/bibliotech/command_generator/postgres_spec.rb +180 -0
  30. data/spec/bibliotech/command_generator_spec.rb +99 -0
  31. data/spec/bibliotech/command_runner_spec.rb +50 -0
  32. data/spec/bibliotech/compression/bunzip2_spec.rb +9 -0
  33. data/spec/bibliotech/compression/bzip2_spec.rb +9 -0
  34. data/spec/bibliotech/compression/gzip_spec.rb +9 -0
  35. data/spec/bibliotech/compression/sevenzip_spec.rb +9 -0
  36. data/spec/bibliotech/compression_spec.rb +28 -0
  37. data/spec/bibliotech/config_spec.rb +151 -0
  38. data/spec/gem_test_suite.rb +0 -0
  39. data/spec/spec_helper.rb +2 -0
  40. metadata +150 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: fa336e5473f11901ab7dcddd786ad077ca6cc9d4
4
+ data.tar.gz: 7823f13b23335c838867c1c6d20dee7707924c39
5
+ SHA512:
6
+ metadata.gz: fb3d5921a720ab1dc4e2b682d8d6be78155a4f6c39a70554b1c83a164696624fbf7fc3188b865c2a8904e20cacb8a1240a1e407867f13bc6eba7da7a675e013f
7
+ data.tar.gz: a16f586a9e18301134a6a454a7cd3749b64994753f4b94a8076ca862bbcf82479f2dd83e508fa08c3a0d3264293fccb9d3d11a0e6b5d7ee1c98f3d7796b3f9fb
data/bin/bibliotech ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bibliotech/cli'
4
+
5
+ BiblioTech::CLI.start(ARGV)
@@ -0,0 +1,58 @@
1
+ # bibliotech.yml on production server
2
+ # used by commands like
3
+ # rake bibliotech:backups:create
4
+ # rake bibliotech:backups:cycle
5
+ backups:
6
+ dir: db_backups
7
+ compress: gzip # [ none, gzip, bzip2, 7zip ]
8
+ keep:
9
+ hourlies: 48
10
+ dailies: 14
11
+ weeklies: 8
12
+ monthlies: all
13
+
14
+ create: hourly
15
+ prune: daily
16
+ clone:
17
+ storage: s3 [ file, ftp, scp ]
18
+ target: ecliptic_db_backups # URL
19
+
20
+ database_config_env: production
21
+ database_config_file: 'config/database.yml' # this is the default
22
+ # -- or --
23
+ database_config:
24
+ hostname: xxxx
25
+ username: xxxxx
26
+ password: xxxxx
27
+ port: xxxx
28
+
29
+ #bibliotech.yml on staging server
30
+ #useful for:
31
+ # > rake bibliotech:restore_from[<remote name, e.g. production>]
32
+ # # (copies most recent database backup from this path or URL and restores it)
33
+ backups: none
34
+ database_config_env: staging
35
+ database_config_file: 'config/database.yml' # this is the default
36
+ # -- or --
37
+ database_config:
38
+ hostname: xxxx
39
+ username: xxxxx
40
+ password: xxxxx
41
+ port: xxxx
42
+
43
+ remotes:
44
+ production:
45
+ host: some.server.com
46
+ path: "/var/www/eclipticenterprises.com/current"
47
+ compressed: gzip
48
+
49
+
50
+ #bibliotech.yml on dev
51
+ # used by commands like
52
+ # > rake bibliotech:remote_sync:down[production] # production is default if ommitted
53
+ # > rake bibliotech:remote_sync:up
54
+ # > cap bibliotech:remote_sync:down[production] # uses Cap config, so URL below is unnecessary in config
55
+ #
56
+ remotes:
57
+ production: "root@appserver2.lrdesign.com:/var/www/eclipticenterprises.com/current"
58
+ staging: "root@appserver2.lrdesign.com:/var/www/staging.eclipticenterprises.com/current"
data/doc/todo.txt ADDED
@@ -0,0 +1,19 @@
1
+ * load DB configs from a database.yml
2
+ * export commands: mysql
3
+ * export commands: posgresql
4
+ * export commands: postgis
5
+ * import commands: mysql
6
+ * import commands: postgresql
7
+ * import commands: postgis
8
+ * rake task: backup databases
9
+ * rake task: filter dated DB backups
10
+ * configuration: how many backups to keep of what age
11
+ *
12
+ * rake task: import DB locally by filename
13
+ * rake task: import[&migrate] most recent DB (at default name) from production, locally
14
+ * rake task: import[&migrate] most recent DB (at default name) from production, remotely
15
+ * rake task: download and import remote sql backup
16
+ * rake task: upload and import local backup to remote:
17
+ *
18
+ * cap task: download and import remote sql backup
19
+ * cap task: upload and import local backup to remote:
@@ -0,0 +1,95 @@
1
+ require 'bibliotech'
2
+ require 'caliph'
3
+ require 'valise'
4
+ require 'bibliotech/backups/pruner'
5
+
6
+ module BiblioTech
7
+ class Application
8
+ attr_accessor :config_path, :config_hash
9
+ attr_writer :shell
10
+
11
+ def initialize
12
+ @memos = {}
13
+ @shell = Caliph.new
14
+ @config_path = %w{/etc/bibliotech /usr/share/bibliotech ~/.bibliotech ./.bibliotech ./config/bibliotech}
15
+ end
16
+
17
+ def valise
18
+ @memos[:valise] ||=
19
+ begin
20
+ dirs = config_path
21
+ Valise::define do
22
+ dirs.reverse.each do |dir|
23
+ rw dir
24
+ end
25
+ ro from_here(%w{.. default_configuration}, up_to("lib"))
26
+ handle "*.yaml", :yaml, :hash_merge
27
+ handle "*.yml", :yaml, :hash_merge
28
+ end
29
+ end
30
+ end
31
+
32
+ def config
33
+ @memos[:config] ||= Config.new(valise)
34
+ end
35
+
36
+ def commands
37
+ @memos[:command] ||= CommandGenerator.new(config)
38
+ end
39
+
40
+ def pruner(options)
41
+ Backups::Pruner.new(config.merge(options))
42
+ end
43
+
44
+ def prune_list(options)
45
+ pruner(options).list
46
+ end
47
+
48
+ def reset
49
+ @memos.clear
50
+ end
51
+
52
+ def import(options)
53
+ @shell.run(commands.import(options))
54
+ end
55
+
56
+ def export(options)
57
+ @shell.run(commands.export(options))
58
+ end
59
+
60
+ def create_backup(options)
61
+ time = Time.now.utc
62
+ pruner = pruner(options)
63
+ return unless pruner.backup_needed?(time)
64
+ options["backups"] ||= options[:backups] || {}
65
+ options["backups"]["filename"] = pruner.filename_for(time)
66
+ export(options)
67
+ end
68
+
69
+ #pull a dump from a remote
70
+ def get(options)
71
+ @shell.run(commands.fetch(options))
72
+ end
73
+
74
+ #push a dump to a remote
75
+ def send(options)
76
+ @shell.run(commands.push(options))
77
+ end
78
+
79
+ #clean up the DB dumps
80
+ def prune(options=nil)
81
+ pruner(option || {}).go
82
+ end
83
+
84
+ #return the latest dump of the DB
85
+ def latest(options = nil)
86
+ pruner(options || {}).most_recent.path
87
+ end
88
+
89
+ def remote_cli(remote, command, options)
90
+ @shell.run(commands.ssh_cli(remote, command, options))
91
+ end
92
+ end
93
+
94
+ App = Application
95
+ end
@@ -0,0 +1,16 @@
1
+ module BiblioTech
2
+ module Backups
3
+ class FileRecord
4
+ attr_accessor :path, :timestamp, :keep
5
+
6
+ def initialize(path, timestamp)
7
+ @path, @timestamp = path, timestamp
8
+ @keep = false
9
+ end
10
+
11
+ def keep?
12
+ !!@keep
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,58 @@
1
+ module BiblioTech
2
+ module Backups
3
+ class PruneList
4
+ TIMESTAMP_REGEX = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})_(?<hour>\d{2}):(?<minute>\d{2})/
5
+
6
+ attr_accessor :path, :prefix
7
+
8
+ def initialize(path, prefix)
9
+ @path, @prefix = path, prefix
10
+ end
11
+
12
+ def list
13
+ files = []
14
+ Dir.new(path).each do |file|
15
+ next if %w{. ..}.include?(file)
16
+ file_record = build_record(file)
17
+ if file_record.nil?
18
+ else
19
+ files << file_record
20
+ end
21
+ end
22
+ files
23
+ end
24
+
25
+ def prefix_timestamp_re
26
+ prefix_re(TIMESTAMP_REGEX)
27
+ end
28
+
29
+ def prefix_re(also)
30
+ /\A#{prefix}-#{also}\..*\z/
31
+ end
32
+
33
+ def self.filename_for(prefix, time)
34
+ time.strftime("#{prefix}-%Y-%m-%d_%H:%M.sql")
35
+ end
36
+
37
+ def build_record(file)
38
+ if file =~ prefix_re(/.*/)
39
+ if !(match = prefix_timestamp_re.match(file)).nil?
40
+ timespec = %w{year month day hour minute}.map do |part|
41
+ Integer(match[part], 10)
42
+ end
43
+ parsed_time = Time::utc(*timespec)
44
+ return FileRecord.new(File::join(path, file), parsed_time)
45
+ else
46
+ raise "File prefixed #{prefix} doesn't match #{prefix_timestamp_re.to_s}: #{File::join(path, file)}"
47
+ end
48
+ else
49
+ if file !~ TIMESTAMP_REGEX
50
+ warn "Stray file in backups directory: #{File::join(path, file)}"
51
+ return nil
52
+ end
53
+ end
54
+ return nil
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,71 @@
1
+ require 'bibliotech/backups/prune_list'
2
+ require 'bibliotech/backups/file_record'
3
+ require 'bibliotech/backups/scheduler'
4
+
5
+ module BiblioTech
6
+ module Backups
7
+ class Pruner
8
+ def initialize(config)
9
+ @config = config
10
+ end
11
+ attr_reader :config
12
+
13
+ def path
14
+ @path ||= config.backup_path
15
+ end
16
+
17
+ def name
18
+ @name ||= config.backup_name
19
+ end
20
+
21
+ def schedules
22
+ @schedules ||=
23
+ [].tap do |array|
24
+ config.each_prune_schedule do |frequency, limit|
25
+ array << Scheduler.new(frequency, limit)
26
+ end
27
+ end
28
+ end
29
+
30
+ def backup_needed?(time)
31
+ time - most_recent.timestamp < config.backup_frequency * 60
32
+ end
33
+
34
+ def list
35
+ @list ||=
36
+ begin
37
+ list = PruneList.new(path, name).list
38
+ schedules.each do |schedule|
39
+ schedule.mark(list)
40
+ end
41
+ list
42
+ end
43
+ end
44
+
45
+ def most_recent
46
+ list.max_by do |record|
47
+ record.timestamp
48
+ end
49
+ end
50
+
51
+ def filename_for(time)
52
+ PruneList.filename_for(time)
53
+ end
54
+
55
+ def pruneable
56
+ list.select do |record|
57
+ !record.keep?
58
+ end
59
+ end
60
+
61
+ def go
62
+ return if schedules.empty?
63
+ pruneable.each {|record| delete(record.path) }
64
+ end
65
+
66
+ def delete(path)
67
+ File.unlink(path)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,49 @@
1
+ require 'bibliotech/backups/file_record'
2
+
3
+ module BiblioTech
4
+ module Backups
5
+ class Scheduler
6
+ attr_accessor :frequency, :limit
7
+
8
+ def initialize(frequency, limit)
9
+ @frequency, @limit = frequency, limit
10
+ @limit = nil if limit == "all"
11
+ end
12
+
13
+ def end_time(file_list)
14
+ file_list.map{|record| record.timestamp}.max
15
+ end
16
+
17
+ def compute_start_time(file_list)
18
+ limit_time = Time.at(0)
19
+ unless limit.nil?
20
+ limit_time = end_time(file_list) - limit * freq_seconds
21
+ end
22
+ [limit_time, file_list.map{|record| record.timestamp}.min - range].max
23
+ end
24
+
25
+ def freq_seconds
26
+ frequency * 60
27
+ end
28
+
29
+ def range
30
+ freq_seconds / 2
31
+ end
32
+
33
+ def mark(file_list)
34
+ time = end_time(file_list)
35
+ start_time = compute_start_time(file_list)
36
+ while time > start_time do
37
+ closest = file_list.min_by do |record|
38
+ (record.timestamp - time).abs
39
+ end
40
+ if (closest.timestamp - time).abs < range
41
+ closest.keep = true
42
+ end
43
+ time -= freq_seconds
44
+ end
45
+ return file_list
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,25 @@
1
+ require 'bibliotech/builders'
2
+
3
+ module BiblioTech
4
+ module Builders
5
+ class Database < Base
6
+ def self.find_class(config)
7
+ adapter_registry.fetch(config.adapter) do
8
+ raise "config.adapter is #{config.adapter.inspect} - supported adapters are #{supported_adapters.join(", ")}"
9
+ end
10
+ end
11
+ end
12
+
13
+ class Import < Database
14
+ def self.registry_host
15
+ Import
16
+ end
17
+ end
18
+
19
+ class Export < Database
20
+ def self.registry_host
21
+ Export
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,75 @@
1
+ require 'bibliotech/builders'
2
+
3
+ module BiblioTech
4
+ module Builders
5
+ class File < Base
6
+ def self.find_class(config)
7
+ file = config.backup_file
8
+
9
+ explicit = find_explicit(config)
10
+ return explicit unless explicit.nil?
11
+
12
+ _, klass = adapter_registry.find{ |pattern, klass|
13
+ next if pattern.is_a? Symbol
14
+ file =~ pattern
15
+ }
16
+ klass || identity_adapter
17
+ rescue Config::MissingConfig
18
+ return NullAdapter
19
+ end
20
+
21
+ def file
22
+ config.backup_file
23
+ end
24
+ end
25
+
26
+ class FileInput < File
27
+ def self.identity_adapter
28
+ IdentityFileInput
29
+ end
30
+
31
+ def self.find_explicit(config)
32
+ return adapter_registry.fetch(config.expander) do
33
+ raise "config.expander is #{config.expander.inspect} - supported expanders are #{supported_adapters.select{|ad| ad.is_a? Symbol}.join(", ")}"
34
+ end
35
+ rescue Config::MissingConfig
36
+ nil
37
+ end
38
+
39
+ def self.registry_host
40
+ FileInput
41
+ end
42
+ end
43
+
44
+ class FileOutput < File
45
+ def self.identity_adapter
46
+ IdentityFileOutput
47
+ end
48
+
49
+ def self.find_explicit(config)
50
+ return adapter_registry.fetch(config.compressor) do
51
+ raise "config.compressor is #{config.compressor.inspect} - supported compressors are #{supported_adapters.select{|ad| ad.is_a? Symbol}.join(", ")}"
52
+ end
53
+ rescue KeyError
54
+ end
55
+
56
+ def self.registry_host
57
+ FileOutput
58
+ end
59
+ end
60
+
61
+ class IdentityFileInput < FileInput
62
+ def go(cmd)
63
+ cmd.redirect_stdin(file)
64
+ cmd
65
+ end
66
+ end
67
+
68
+ class IdentityFileOutput < FileOutput
69
+ def go(cmd)
70
+ cmd.redirect_stdout(file)
71
+ cmd
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,51 @@
1
+ require 'bibliotech/builders/file'
2
+
3
+ module BiblioTech
4
+ module Builders
5
+ class GzipExpander < FileInput
6
+ register(/.*\.gz\z/)
7
+ register(/.*\.gzip\z/)
8
+
9
+ def go(command)
10
+ command = cmd("gunzip", file) | command
11
+ end
12
+ end
13
+
14
+ class ExplicitGzipExpander < GzipExpander
15
+ register :gzip
16
+
17
+ def file
18
+ file = super
19
+ unless PATTERNS.any?{|pattern| pattern =~ file}
20
+ return file + ".gz"
21
+ end
22
+ file
23
+ end
24
+ end
25
+
26
+ class GzipCompressor < FileOutput
27
+ PATTERNS = [ /.*\.gz\z/, /.*\.gzip\z/ ]
28
+ PATTERNS.each do |pattern|
29
+ register pattern
30
+ end
31
+
32
+ def go(cmd)
33
+ cmd |= %w{gzip}
34
+ cmd.redirect_stdout(file)
35
+ cmd
36
+ end
37
+ end
38
+
39
+ class ExplicitGzipCompressor < GzipCompressor
40
+ register :gzip
41
+
42
+ def file
43
+ file = super
44
+ unless PATTERNS.any?{|pattern| pattern =~ file}
45
+ return file + ".gz"
46
+ end
47
+ file
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,35 @@
1
+ require 'bibliotech/builders/database'
2
+
3
+ module BiblioTech
4
+ module Builders
5
+ module MySql
6
+ class Export < Builders::Export
7
+ register :mysql
8
+
9
+ def go(command)
10
+ command.from('mysqldump')
11
+ config.optional{ command.options << "-h #{config.host}" }
12
+ config.optional{ command.options << "-u #{config.username}" }
13
+ config.optional{ command.options << "-P #{config.port}" } #ok
14
+ config.optional{ command.options << "--password='#{config.password}'" }
15
+ command.options << "#{config.database}"
16
+ command
17
+ end
18
+ end
19
+
20
+ class Import < Builders::Import
21
+ register :mysql
22
+
23
+ def go(command)
24
+ command.from('mysql')
25
+ config.optional{ command.options << "-h #{config.host}" }
26
+ config.optional{ command.options << "-u #{config.username}" }
27
+ config.optional{ command.options << "-P #{config.port}" } #ok
28
+ config.optional{ command.options << "--password='#{config.password}'" }
29
+ command.options << "#{config.database}"
30
+ command
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,37 @@
1
+ require 'bibliotech/builders/database'
2
+
3
+ module BiblioTech
4
+ module Builders
5
+ module Postgres
6
+ class Export < Builders::Export
7
+ register :postgres
8
+
9
+ def go(command)
10
+ command.from('pg_dump', '-Fc')
11
+ config.optional{ command.options << "-h #{config.host}" }
12
+ config.optional{ command.env["PGPASSWORD"] = config.password }
13
+ config.optional{ command.options << "-p #{config.port}" } #ok
14
+
15
+ command.options << "-U #{config.username}"
16
+ command.options << "#{config.database}"
17
+ command
18
+ end
19
+ end
20
+
21
+ class Import < Builders::Import
22
+ register :postgres
23
+
24
+ def go(command)
25
+ command.from('pg_restore')
26
+ config.optional{ command.options << "-h #{config.host}" }
27
+ config.optional{ command.env["PGPASSWORD"] = config.password }
28
+ config.optional{ command.options << "-p #{config.port}" } #ok
29
+
30
+ command.options << "-U #{config.username}"
31
+ command.options << "-d #{config.database}"
32
+ command
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,43 @@
1
+ module BiblioTech
2
+ module Builders
3
+ class Base
4
+ include Caliph::CommandLineDSL
5
+ class << self
6
+ def register(adapter_name)
7
+ adapter_registry[adapter_name] = self
8
+ end
9
+
10
+ def adapter_registry
11
+ registry_host.registry
12
+ end
13
+
14
+ def registry
15
+ @registry ||={}
16
+ end
17
+
18
+ def supported_adapters
19
+ adapter_registry.keys
20
+ end
21
+
22
+ def for(config)
23
+ find_class(config).new(config)
24
+ end
25
+
26
+ def null_adapter
27
+ NullAdapter
28
+ end
29
+ end
30
+
31
+ def initialize(config)
32
+ @config = config
33
+ end
34
+ attr_reader :config
35
+ end
36
+
37
+ class NullAdapter < Base
38
+ def go(cmd)
39
+ cmd
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,24 @@
1
+ require 'thor'
2
+ require 'bibliotech/application'
3
+
4
+ module BiblioTech
5
+ class CLI < Thor
6
+ desc "latest", "Outputs the latest DB dump available locally"
7
+ def latest
8
+ app = App.new
9
+ app.latest
10
+ end
11
+
12
+ desc "dump FILENAME", "Create a new database dump into FILE"
13
+ def dump(file)
14
+ app = App.new
15
+ app.export(:backups => { :filename => file })
16
+ end
17
+
18
+ desc "load FILENAME", "Load a database file from FILE"
19
+ def load(file)
20
+ app = App.new
21
+ app.import(:backups => { :filename => file })
22
+ end
23
+ end
24
+ end