capistrano-db_sync 0.0.2
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.
- checksums.yaml +7 -0
- data/.gitignore +34 -0
- data/Gemfile +15 -0
- data/LICENSE +22 -0
- data/README.md +83 -0
- data/Rakefile +7 -0
- data/capistrano-db_sync.gemspec +22 -0
- data/lib/capistrano/db_sync/configuration.rb +61 -0
- data/lib/capistrano/db_sync/executor/base.rb +35 -0
- data/lib/capistrano/db_sync/executor/local.rb +38 -0
- data/lib/capistrano/db_sync/executor/remote.rb +56 -0
- data/lib/capistrano/db_sync/postgres/cli.rb +71 -0
- data/lib/capistrano/db_sync/postgres/exporter.rb +61 -0
- data/lib/capistrano/db_sync/postgres/file_name_generator.rb +22 -0
- data/lib/capistrano/db_sync/postgres/importer.rb +79 -0
- data/lib/capistrano/db_sync/postgres.rb +9 -0
- data/lib/capistrano/db_sync/tasks.rake +25 -0
- data/lib/capistrano/db_sync/version.rb +5 -0
- data/lib/capistrano/db_sync.rb +7 -0
- data/spec/lib/capistrano/db_sync/postgres/cli_spec.rb +30 -0
- data/spec/lib/capistrano/db_sync/postgres/exporter_spec.rb +31 -0
- data/spec/lib/capistrano/db_sync/postgres/importer_spec.rb +43 -0
- data/spec/spec_helper.rb +5 -0
- metadata +84 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 560f9902b7556b03a2206f1b3125d68d906000ed
|
4
|
+
data.tar.gz: fc422d26be8a12e80bae35f88e9918286439a3ec
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 001d12a9c6bbfce2689c49d47d6c9c8752d7792fa28355149b120306da264a33e40f4047ca41255067fb57ec4080832611006699619bed491dd009abbc2d2ca8
|
7
|
+
data.tar.gz: 1b0d4fdfd2210b57bedba454ef62446c952196462e5affd89effece893ba10d6fcf3b531d704b573ecfe837b72b1b60bc07ec0111c0ba3aaa3bccda5f9141909
|
data/.gitignore
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
*.gem
|
2
|
+
*.rbc
|
3
|
+
/.config
|
4
|
+
/coverage/
|
5
|
+
/InstalledFiles
|
6
|
+
/pkg/
|
7
|
+
/spec/reports/
|
8
|
+
/test/tmp/
|
9
|
+
/test/version_tmp/
|
10
|
+
/tmp/
|
11
|
+
|
12
|
+
## Specific to RubyMotion:
|
13
|
+
.dat*
|
14
|
+
.repl_history
|
15
|
+
build/
|
16
|
+
|
17
|
+
## Documentation cache and generated files:
|
18
|
+
/.yardoc/
|
19
|
+
/_yardoc/
|
20
|
+
/doc/
|
21
|
+
/rdoc/
|
22
|
+
|
23
|
+
## Environment normalisation:
|
24
|
+
/.bundle/
|
25
|
+
/lib/bundler/man/
|
26
|
+
|
27
|
+
# for a library or gem, you might want to ignore these files since the code is
|
28
|
+
# intended to run in multiple environments; otherwise, check them in:
|
29
|
+
Gemfile.lock
|
30
|
+
.ruby-version
|
31
|
+
.ruby-gemset
|
32
|
+
|
33
|
+
# unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
|
34
|
+
.rvmrc
|
data/Gemfile
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
source 'https://rubygems.org'
|
2
|
+
|
3
|
+
# Specify your gem's dependencies in capistrano-pg_sync.gemspec
|
4
|
+
gemspec
|
5
|
+
|
6
|
+
group :development, :test do
|
7
|
+
gem "bundler", "~> 1.7"
|
8
|
+
gem "rake", "~> 10.0"
|
9
|
+
gem "pry-byebug"
|
10
|
+
end
|
11
|
+
|
12
|
+
group :test do
|
13
|
+
gem "minitest"
|
14
|
+
gem "mocha"
|
15
|
+
end
|
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2015 Rafael Sales
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
22
|
+
|
data/README.md
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
## Capistrano::DBSync
|
2
|
+
|
3
|
+
Fast and sophisticated remote database import using the best of **Postgres 9.2.x**
|
4
|
+
|
5
|
+
### Features
|
6
|
+
|
7
|
+
* Allows dump data selectively - partial table data or no data
|
8
|
+
* Uses Postgres parallel restore
|
9
|
+
* Uses Postgres custom dump format that is automatically compressed
|
10
|
+
|
11
|
+
### Requirements
|
12
|
+
|
13
|
+
* Capistrano 3.x
|
14
|
+
* Postgres 9.2.x
|
15
|
+
* It was tested with Rails only, but it is expected to work in any project containing
|
16
|
+
`config/database.yml` file in both local and remote machine.
|
17
|
+
|
18
|
+
### Installation
|
19
|
+
|
20
|
+
1. Add this line to your application's Gemfile:
|
21
|
+
|
22
|
+
```ruby
|
23
|
+
gem 'capistrano-pg_sync', require: false
|
24
|
+
```
|
25
|
+
|
26
|
+
2. Define your custom settings, if needed. We suggest to locate this file at `lib/capistrano/tasks/db_sync.rake`.
|
27
|
+
Capistrano 3.x should load all `*.rake` files by default in `Capfile`.
|
28
|
+
[See the complete configuration reference](/lib/capistrano/db_sync/configuration.rb)
|
29
|
+
|
30
|
+
```ruby
|
31
|
+
require 'capistrano/db_sync'
|
32
|
+
|
33
|
+
set :db_sync_options, -> do
|
34
|
+
{
|
35
|
+
# Hash mapping a table name to a query with data selection or nil in case no data
|
36
|
+
# is wanted for a table. Tables not listed here will be dumped entirely.
|
37
|
+
data_selection: {
|
38
|
+
posts: "SELECT * FROM posts WHERE created_at > NOW() - interval '60 days'",
|
39
|
+
comments: "SELECT * FROM comments WHERE created_at > NOW() - interval '30 days'",
|
40
|
+
likes: nil
|
41
|
+
},
|
42
|
+
|
43
|
+
local: {
|
44
|
+
cleanup: false, # If the downloaded dump directory should be removed after restored
|
45
|
+
pg_jobs: 2, # Number of jobs to run in parallel on pg_restore
|
46
|
+
},
|
47
|
+
|
48
|
+
remote: {
|
49
|
+
cleanup: true, # If the remote dump directory should be removed after downloaded
|
50
|
+
}
|
51
|
+
}
|
52
|
+
end
|
53
|
+
```
|
54
|
+
|
55
|
+
### Usage
|
56
|
+
|
57
|
+
```sh-session
|
58
|
+
$ cap production db_sync:import
|
59
|
+
```
|
60
|
+
|
61
|
+
### How it works
|
62
|
+
|
63
|
+
The following steps describe what happens when executing `cap production db_sync:import`:
|
64
|
+
|
65
|
+
1. SSH into production server with primary db role on capistrano stages configuration
|
66
|
+
2. Connect to the remote Postgres using credentials of `config/database.yml` in the
|
67
|
+
deployed server
|
68
|
+
3. Dump the database schema, data, triggers, constraints, rules and indexes
|
69
|
+
4. Download the compressed dump files to local machine
|
70
|
+
5. Restore the dumps in local machine in following sequence
|
71
|
+
1. database schema
|
72
|
+
2. data for tables with entire data dumped
|
73
|
+
3. data for tables with partial data specified in configuration
|
74
|
+
4. triggers, constraints, rules and indexes
|
75
|
+
|
76
|
+
|
77
|
+
### Contributing
|
78
|
+
|
79
|
+
1. Fork it ( https://github.com/rafaelsales/capistrano-db_sync/fork )
|
80
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
81
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
82
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
83
|
+
5. Create a new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'capistrano/db_sync/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "capistrano-db_sync"
|
8
|
+
spec.version = Capistrano::DBSync::VERSION
|
9
|
+
spec.authors = ["Rafael Sales"]
|
10
|
+
spec.email = ["rafaelcds@gmail.com"]
|
11
|
+
spec.summary = %q{A capistrano task to import remote Postgres databases}
|
12
|
+
spec.description = %q{Fast download and restore dumps using edge features of Postgres 9.x}
|
13
|
+
spec.homepage = "https://github.com/rafaelsales/capistrano-db_sync"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_runtime_dependency "capistrano", ">= 3.0.0"
|
22
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module Capistrano::DBSync
|
2
|
+
class Configuration
|
3
|
+
extend Forwardable
|
4
|
+
|
5
|
+
DEFAULT_OPTIONS = ->(cap) do
|
6
|
+
{
|
7
|
+
# Hash mapping a table name to a query or nil in case no data is wanted for a table.
|
8
|
+
# E.g.: {
|
9
|
+
# posts: "SELECT * FROM posts WHERE created_at > NOW() - interval '60 days'",
|
10
|
+
# comments: "SELECT * FROM comments WHERE created_at > NOW() - interval '30 days'",
|
11
|
+
# likes: nil
|
12
|
+
# }
|
13
|
+
data_selection: {},
|
14
|
+
|
15
|
+
data_sync_confirmation: true, # Ask for user input confirmation
|
16
|
+
|
17
|
+
local: {
|
18
|
+
cleanup: false, # If the downloaded dump directory should be removed after restored
|
19
|
+
|
20
|
+
pg_jobs: 1, # Number of jobs to run in parallel on pg_restore
|
21
|
+
|
22
|
+
working_dir: "./tmp",
|
23
|
+
env: ENV.fetch('RAILS_ENV', 'development'),
|
24
|
+
},
|
25
|
+
|
26
|
+
remote: {
|
27
|
+
cleanup: true, # If the remote dump directory should be removed after downloaded
|
28
|
+
|
29
|
+
working_dir: "/tmp",
|
30
|
+
env: cap.fetch(:stage).to_s,
|
31
|
+
},
|
32
|
+
}
|
33
|
+
end
|
34
|
+
|
35
|
+
def initialize(cap_instance = Capistrano.env)
|
36
|
+
@cap = cap_instance
|
37
|
+
@options = load_options
|
38
|
+
end
|
39
|
+
|
40
|
+
def load_options
|
41
|
+
user_options = cap.fetch(:db_sync_options)
|
42
|
+
DEFAULT_OPTIONS.call(cap).deep_merge(user_options)
|
43
|
+
end
|
44
|
+
|
45
|
+
def data_sync_confirmed?
|
46
|
+
skip = options[:data_sync_confirmation].to_s.downcase == "false"
|
47
|
+
skip || prompt("Confirm replace local database with remote database?")
|
48
|
+
end
|
49
|
+
|
50
|
+
def_delegators :@options, :[], :fetch
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
attr_reader :cap, :options
|
55
|
+
|
56
|
+
def prompt(message, prompt = "(y)es, (n)o")
|
57
|
+
cap.ask(:prompt_answer, "#{message} #{prompt}")
|
58
|
+
(cap.fetch(:prompt_answer) =~ /^y|yes$/i) == 0
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Capistrano::DBSync
|
2
|
+
module Executor
|
3
|
+
class Base
|
4
|
+
|
5
|
+
# +side+ must be :local or :remote
|
6
|
+
def initialize(cap, config, side)
|
7
|
+
@cap = cap
|
8
|
+
@config = config
|
9
|
+
@session_id = Time.now.strftime("%Y-%m-%d-%H%M%S")
|
10
|
+
@side = side
|
11
|
+
end
|
12
|
+
|
13
|
+
def working_dir
|
14
|
+
File.join config[side][:working_dir]
|
15
|
+
end
|
16
|
+
|
17
|
+
def env
|
18
|
+
config[side][:env].to_s
|
19
|
+
end
|
20
|
+
|
21
|
+
def cleanup?
|
22
|
+
config[side][:cleanup]
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def load_db_config!(config_file_contents)
|
28
|
+
yaml = YAML.load(ERB.new(config_file_contents).result)
|
29
|
+
@db_config = yaml[env].tap { |db_config| Postgres.validate!(db_config) }
|
30
|
+
end
|
31
|
+
|
32
|
+
attr_reader :cap, :config, :db_config, :side, :session_id
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require "fileutils"
|
2
|
+
|
3
|
+
module Capistrano::DBSync
|
4
|
+
module Executor
|
5
|
+
class Local < Base
|
6
|
+
def initialize(cap, config)
|
7
|
+
super(cap, config, :local)
|
8
|
+
load_db_config!(File.read File.join("config", "database.yml"))
|
9
|
+
end
|
10
|
+
|
11
|
+
def restore!(dump_dir)
|
12
|
+
importer(dump_dir).restore(jobs: config[:local][:pg_jobs]).each do |cmd|
|
13
|
+
cap.info "Running locally: #{cmd}"
|
14
|
+
system(cmd)
|
15
|
+
end
|
16
|
+
|
17
|
+
cap.info "Completed database restore."
|
18
|
+
ensure
|
19
|
+
clean_dump_if_needed!(dump_dir)
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def clean_dump_if_needed!(dump_dir)
|
25
|
+
if cleanup?
|
26
|
+
FileUtils.rm_rf dump_dir
|
27
|
+
cap.info "Removed #{dump_dir} locally."
|
28
|
+
else
|
29
|
+
cap.info "Leaving #{dump_dir} locally. Use \"local: { cleanup: true }\" to remove."
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def importer(dump_dir)
|
34
|
+
Postgres::Importer.new(dump_dir, db_config)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module Capistrano::DBSync
|
2
|
+
module Executor
|
3
|
+
class Remote < Base
|
4
|
+
def initialize(cap, config)
|
5
|
+
super(cap, config, :remote)
|
6
|
+
load_db_config! cap.capture("cat #{File.join cap.current_path, 'config', 'database.yml'}")
|
7
|
+
end
|
8
|
+
|
9
|
+
# Returns the dump directory location that was downloaded to local
|
10
|
+
# machine, which is based on +local_working_dir+.
|
11
|
+
def dump_and_download_to!(local_working_dir)
|
12
|
+
dump!
|
13
|
+
download_to!(local_working_dir)
|
14
|
+
ensure
|
15
|
+
clean_dump_if_needed!
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def dump!
|
21
|
+
cap.execute "mkdir -p #{dump_dir}"
|
22
|
+
|
23
|
+
exporter.dump(data_selection: config[:data_selection]).each do |cmd|
|
24
|
+
cap.execute cmd
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def download_to!(local_working_dir)
|
29
|
+
system "mkdir -p #{local_working_dir}"
|
30
|
+
cap.download! dump_dir, local_working_dir, recursive: true
|
31
|
+
|
32
|
+
cap.info "Completed database dump and download."
|
33
|
+
File.join(local_working_dir, File.basename(dump_dir))
|
34
|
+
end
|
35
|
+
|
36
|
+
def clean_dump_if_needed!
|
37
|
+
if cleanup?
|
38
|
+
cap.execute "rm -rf #{dump_dir}"
|
39
|
+
cap.info "Removed #{dump_dir} from the server."
|
40
|
+
else
|
41
|
+
cap.info "Leaving #{dump_dir} on the server. Use \"remote: { cleanup: true}\" to remove."
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def exporter
|
48
|
+
Postgres::Exporter.new(dump_dir, db_config)
|
49
|
+
end
|
50
|
+
|
51
|
+
def dump_dir
|
52
|
+
File.join(working_dir, "dump_#{session_id}_#{db_config['database']}")
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
class Capistrano::DBSync::Postgres::CLI
|
2
|
+
def initialize(config)
|
3
|
+
@config = config
|
4
|
+
end
|
5
|
+
|
6
|
+
def dump(to_file, db, options = [])
|
7
|
+
"#{with_pw} pg_dump #{credentials} #{format_args} -f #{to_file} #{options.join(' ')} #{db}"
|
8
|
+
end
|
9
|
+
|
10
|
+
def restore(from_file, db, options = [])
|
11
|
+
"#{with_pw} pg_restore #{credentials} #{format_args} -d #{db} #{options.join(' ')} #{from_file}"
|
12
|
+
end
|
13
|
+
|
14
|
+
def drop_db(db)
|
15
|
+
psql %Q|DROP DATABASE IF EXISTS "#{db}";|
|
16
|
+
end
|
17
|
+
|
18
|
+
def create_db(db)
|
19
|
+
psql %Q|CREATE DATABASE "#{db}";|
|
20
|
+
end
|
21
|
+
|
22
|
+
def rename_db(old_db, new_db)
|
23
|
+
psql %Q|ALTER DATABASE "#{old_db}" RENAME TO "#{new_db}";|
|
24
|
+
end
|
25
|
+
|
26
|
+
def clone_db(new_db, template_db)
|
27
|
+
psql %Q|CREATE DATABASE "#{new_db}" WITH TEMPLATE "#{template_db}";|
|
28
|
+
end
|
29
|
+
|
30
|
+
def psql(command, db = "postgres")
|
31
|
+
normalized_command = command.gsub('"', '\"').gsub(/\s\s+|\n/, " ")
|
32
|
+
%Q|#{with_pw} psql #{credentials} -d #{db} -c "#{normalized_command}"|
|
33
|
+
end
|
34
|
+
|
35
|
+
def kill_processes_for_db(db)
|
36
|
+
psql <<-SQL.gsub("$", "\\$")
|
37
|
+
SELECT pg_terminate_backend(pg_stat_activity.pid)
|
38
|
+
FROM pg_stat_activity
|
39
|
+
WHERE pg_stat_activity.datname = $$#{db}$$
|
40
|
+
AND pid <> pg_backend_pid();
|
41
|
+
SQL
|
42
|
+
end
|
43
|
+
|
44
|
+
def copy_to_file(to_compressed_file, db, query)
|
45
|
+
psql "\\COPY (#{query}) TO PROGRAM 'gzip > #{to_compressed_file}'", db
|
46
|
+
end
|
47
|
+
|
48
|
+
def copy_from_file(from_compressed_file, db, table)
|
49
|
+
psql "\\COPY #{table} FROM PROGRAM 'gunzip --to-stdout #{from_compressed_file}'", db
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def format_args
|
55
|
+
"--no-acl --no-owner --format=custom"
|
56
|
+
end
|
57
|
+
|
58
|
+
def credentials
|
59
|
+
credentials_params = []
|
60
|
+
credentials_params << "-U #{config['username']}" unless config.fetch('username', '').empty?
|
61
|
+
credentials_params << "-h #{config['host']}" unless config.fetch('host', '').empty?
|
62
|
+
credentials_params << "-p #{config['port']}" unless config.fetch('port', '').empty?
|
63
|
+
credentials_params.join(" ")
|
64
|
+
end
|
65
|
+
|
66
|
+
def with_pw
|
67
|
+
"PGPASSWORD='#{config['password']}'" unless config.fetch('password', '').empty?
|
68
|
+
end
|
69
|
+
|
70
|
+
attr_reader :config, :session_id
|
71
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module Capistrano::DBSync
|
2
|
+
class Postgres::Exporter
|
3
|
+
|
4
|
+
# +working_dir+: The location where the dump files will be stored for dump or read for restore.
|
5
|
+
# +config+: database configuration hash with following skeleton:
|
6
|
+
# {
|
7
|
+
# "database" => "faceburger_production",
|
8
|
+
# "username" => "fb_prod",
|
9
|
+
# "password" => "BestBurger",
|
10
|
+
# "host" => "10.20.30.40",
|
11
|
+
# "port" => "5432"
|
12
|
+
# }
|
13
|
+
def initialize(working_dir, config)
|
14
|
+
@working_dir = working_dir
|
15
|
+
@config = config
|
16
|
+
@cli = Postgres::CLI.new(config)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Returns a set of commands to dump a database with table data selection support.
|
20
|
+
#
|
21
|
+
# +db+ (optional): Database name to dump
|
22
|
+
# +data_selection+ (optional): A hash mapping a table name to a query or nil in
|
23
|
+
# case no data is wanted for a table.
|
24
|
+
#
|
25
|
+
# Example:
|
26
|
+
#
|
27
|
+
# dump("/tmp/dump",
|
28
|
+
# data_selection:
|
29
|
+
# posts: "SELECT * FROM posts WHERE created_at > NOW() - interval '60 days'",
|
30
|
+
# comments: "SELECT * FROM comments WHERE created_at > NOW() - interval '30 days'",
|
31
|
+
# likes: nil
|
32
|
+
# },
|
33
|
+
# db: "faceburger_production")
|
34
|
+
#
|
35
|
+
# This outputs commands that will generate dump files as:
|
36
|
+
# /tmp/dump/0001-faceburger_production.schema -- will contain db schema and data except
|
37
|
+
# for tables posts, comments and likes
|
38
|
+
# /tmp/dump/0002-posts.table -- will contain partial data of table posts
|
39
|
+
# /tmp/dump/0003.comments.table -- will contain partial data of table comments
|
40
|
+
#
|
41
|
+
def dump(db = config["database"], data_selection: {})
|
42
|
+
file_namer = Postgres::FileNameGenerator.new(working_dir)
|
43
|
+
exclude_tables_args = data_selection.keys.map { |table| %Q|--exclude-table-data="#{table}"| }
|
44
|
+
|
45
|
+
[
|
46
|
+
cli.dump(file_namer.next(db, :schema), db, [exclude_tables_args]),
|
47
|
+
*dump_partial_selected_data(db, file_namer, data_selection)
|
48
|
+
]
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def dump_partial_selected_data(db, file_namer, data_selection)
|
54
|
+
data_selection.map do |table, query|
|
55
|
+
cli.copy_to_file(file_namer.next(table, :table), db, query) if query
|
56
|
+
end.compact
|
57
|
+
end
|
58
|
+
|
59
|
+
attr_reader :working_dir, :config, :cli
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
class Capistrano::DBSync::Postgres::FileNameGenerator
|
2
|
+
def initialize(path)
|
3
|
+
@path = path
|
4
|
+
@sequence = 0
|
5
|
+
end
|
6
|
+
|
7
|
+
# Generates sequential file names.
|
8
|
+
# Examples:
|
9
|
+
# next("faceburger", :schema) => 0001-faceburger.schema
|
10
|
+
# next("posts", :table) => 0002-posts.table
|
11
|
+
# next("comments", :table) => 0003-comments.table
|
12
|
+
def next(name, extension)
|
13
|
+
raise ArgumentError unless [:schema, :table].include?(extension)
|
14
|
+
@sequence += 1
|
15
|
+
File.join(@path, "%04i-#{name}.#{extension}" % @sequence)
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.extract_name(file_path)
|
19
|
+
file_name = File.basename(file_path)
|
20
|
+
file_name.scan(/\d+-(.*)\.(schema|table)$/).flatten.first
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
module Capistrano::DBSync
|
2
|
+
class Postgres::Importer
|
3
|
+
|
4
|
+
# +working_dir+: The location where the dump files will be stored for dump or read for restore.
|
5
|
+
# +config+: database configuration hash with following skeleton:
|
6
|
+
# {
|
7
|
+
# "database" => "faceburger_production",
|
8
|
+
# "username" => "fb_prod",
|
9
|
+
# "password" => "BestBurger",
|
10
|
+
# "host" => "10.20.30.40",
|
11
|
+
# "port" => "5432"
|
12
|
+
# }
|
13
|
+
def initialize(working_dir, config)
|
14
|
+
@working_dir = working_dir
|
15
|
+
@config = config
|
16
|
+
@cli = Postgres::CLI.new(config)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Returns a set of commands to dump a database with table data selection support.
|
20
|
+
#
|
21
|
+
# +db+ (optional): Database name to restore data into.
|
22
|
+
# +jobs+ (optional): Number of concurrent jobs that Postgres will run to restore.
|
23
|
+
#
|
24
|
+
# The +working_dir+ should contain files with following name and extension patterns:
|
25
|
+
#
|
26
|
+
# /tmp/dump/0001-faceburger_production.schema -- contains db schema and data except
|
27
|
+
# for tables posts and comments
|
28
|
+
# /tmp/dump/0002-posts.table -- contains partial data of table posts
|
29
|
+
# /tmp/dump/0003.comments.table -- contains partial data of table comments
|
30
|
+
def restore(db = config["database"], jobs: 1)
|
31
|
+
temp_db = "#{db}_#{Time.now.strftime("%Y%m%d%H%M%S")}"
|
32
|
+
|
33
|
+
[
|
34
|
+
cli.kill_processes_for_db(temp_db),
|
35
|
+
cli.drop_db(temp_db),
|
36
|
+
cli.create_db(temp_db),
|
37
|
+
*restore_files_to(temp_db, jobs),
|
38
|
+
cli.kill_processes_for_db(db),
|
39
|
+
cli.drop_db(db),
|
40
|
+
cli.rename_db(temp_db, db)
|
41
|
+
]
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
# Commands to restore pg_dump output file and partial tables data files generated by
|
47
|
+
# Postgres COPY command.
|
48
|
+
def restore_files_to(to_db, jobs)
|
49
|
+
[
|
50
|
+
restore_pg_dump_file(to_db, %w(pre-data data), jobs),
|
51
|
+
*restore_table_copy_files(to_db),
|
52
|
+
restore_pg_dump_file(to_db, %w(post-data), jobs)
|
53
|
+
]
|
54
|
+
end
|
55
|
+
|
56
|
+
# The +sections+ argument must match one of the following supported sections on pg_dump command:
|
57
|
+
# - Pre-data: include all other data definition items.
|
58
|
+
# - Data: contains actual table data, large-object contents, and sequence values.
|
59
|
+
# - Post-data: include definitions of indexes, triggers, rules, and constraints other
|
60
|
+
# than validated check constraints.
|
61
|
+
def restore_pg_dump_file(to_db, sections = %w(pre-data data post-data), jobs)
|
62
|
+
files = Dir.glob(File.join(working_dir, "*.schema"))
|
63
|
+
raise "Expected to have only one pg_dump file, but found these: #{files.inspect}" if files.size > 1
|
64
|
+
|
65
|
+
sections_args = sections.map { |section| "--section=#{section}" }
|
66
|
+
cli.restore(files.first, to_db, [*sections_args, "--jobs=#{jobs}"])
|
67
|
+
end
|
68
|
+
|
69
|
+
def restore_table_copy_files(to_db)
|
70
|
+
files = Dir.glob(File.join(working_dir, "*.table"))
|
71
|
+
files.map do |file|
|
72
|
+
table_name = Postgres::FileNameGenerator.extract_name(file)
|
73
|
+
cli.copy_from_file(file, to_db, table_name)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
attr_reader :working_dir, :config, :cli
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require "capistrano"
|
2
|
+
require "capistrano/version"
|
3
|
+
Bundler.require(:development)
|
4
|
+
|
5
|
+
namespace :db_sync do
|
6
|
+
desc <<-DESC
|
7
|
+
Synchronize your local database using remote database data.
|
8
|
+
Usage: $ cap <stage> db:pull
|
9
|
+
DESC
|
10
|
+
|
11
|
+
task :import do
|
12
|
+
config = Capistrano::DBSync::Configuration.new(self)
|
13
|
+
|
14
|
+
if config.data_sync_confirmed?
|
15
|
+
on roles(:db, primary: true) do
|
16
|
+
local = Capistrano::DBSync::Executor::Local.new(self, config)
|
17
|
+
remote = Capistrano::DBSync::Executor::Remote.new(self, config)
|
18
|
+
|
19
|
+
downloaded_dir = remote.dump_and_download_to! config[:local][:working_dir]
|
20
|
+
|
21
|
+
local.restore!(downloaded_dir)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Capistrano::DBSync::Postgres::CLI do
|
4
|
+
let(:config) do
|
5
|
+
{
|
6
|
+
"database" => "staging",
|
7
|
+
"username" => "user",
|
8
|
+
"password" => "pw",
|
9
|
+
"adapter" => "postgresql",
|
10
|
+
"host" => "127.0.0.1",
|
11
|
+
"port" => "5432",
|
12
|
+
}
|
13
|
+
end
|
14
|
+
|
15
|
+
let(:cli) { Capistrano::DBSync::Postgres::CLI.new(config) }
|
16
|
+
|
17
|
+
describe "#dump" do
|
18
|
+
it "generates pg_dump command" do
|
19
|
+
command = cli.dump("/tmp/staging.dump", "staging", ["--section=pre-data"])
|
20
|
+
command.must_equal "PGPASSWORD='pw' pg_dump -U user -h 127.0.0.1 -p 5432 --no-acl --no-owner --format=custom -f /tmp/staging.dump --section=pre-data staging"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
describe "#restore" do
|
25
|
+
it "generates pg_dump command" do
|
26
|
+
command = cli.restore("/db/production.dump", "staging", ["--jobs=3"])
|
27
|
+
command.must_equal "PGPASSWORD='pw' pg_restore -U user -h 127.0.0.1 -p 5432 --no-acl --no-owner --format=custom -d staging --jobs=3 /db/production.dump"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Capistrano::DBSync::Postgres::Exporter do
|
4
|
+
let(:working_dir) { "/tmp/dumps/" }
|
5
|
+
let(:config) { { "database" => "faceburger_production", "host" => "10.20.30.40" } }
|
6
|
+
let(:exporter) { Capistrano::DBSync::Postgres::Exporter.new(working_dir, config) }
|
7
|
+
|
8
|
+
describe "#dump" do
|
9
|
+
let(:data_selection) do
|
10
|
+
{
|
11
|
+
campaigns: "SELECT * FROM campaigns WHERE date > NOW() - interval '160 days'",
|
12
|
+
keywords: "SELECT * FROM keywords WHERE created_at > NOW() - interval '160 days'",
|
13
|
+
phone_calls: nil
|
14
|
+
}
|
15
|
+
end
|
16
|
+
|
17
|
+
it "restore dump files" do
|
18
|
+
commands = exporter.dump(data_selection: data_selection)
|
19
|
+
|
20
|
+
# Assert dumping database schema with data except for tables specified on data_selection
|
21
|
+
commands[0].must_match /pg_dump.* -f \/tmp\/dumps\/0001-faceburger_production\.schema/
|
22
|
+
commands[0].must_match /--exclude-table-data="campaigns"/
|
23
|
+
commands[0].must_match /--exclude-table-data="keywords"/
|
24
|
+
commands[0].must_match /--exclude-table-data="phone_calls"/
|
25
|
+
|
26
|
+
# Assert dumping data for tables specified on data_selection
|
27
|
+
commands[1].must_match /COPY.*campaigns.*\/tmp\/dumps\/0002-campaigns\.table/
|
28
|
+
commands[2].must_match /COPY.*keywords.*\/tmp\/dumps\/0003-keywords\.table/
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Capistrano::DBSync::Postgres::Importer do
|
4
|
+
let(:working_dir) { "/tmp/dumps/" }
|
5
|
+
let(:config) { { "database" => "faceburger_development", "host" => "localhost" } }
|
6
|
+
let(:importer) { Capistrano::DBSync::Postgres::Importer.new(working_dir, config) }
|
7
|
+
|
8
|
+
describe "#restore" do
|
9
|
+
before do
|
10
|
+
Dir.stubs(:glob).with("/tmp/dumps/*.schema")
|
11
|
+
.returns(["/tmp/dumps/0001-faceburger_production.schema"])
|
12
|
+
|
13
|
+
Dir.stubs(:glob).with("/tmp/dumps/*.table")
|
14
|
+
.returns(["/tmp/dumps/0002-campaigns.table", "/tmp/dumps/0003-keywords.table"])
|
15
|
+
end
|
16
|
+
|
17
|
+
it "restore dump files" do
|
18
|
+
commands = importer.restore(jobs: 3)
|
19
|
+
|
20
|
+
# Assert drop and create temporary database
|
21
|
+
commands[0].must_match /pg_terminate_backend.*faceburger_development_\d+/m
|
22
|
+
commands[1].must_match /DROP DATABASE IF EXISTS.*faceburger_development_\d+/
|
23
|
+
commands[2].must_match /CREATE DATABASE.*faceburger_development_\d+/
|
24
|
+
|
25
|
+
# Assert restore schema definition and data of tables with full data
|
26
|
+
commands[3].must_match /pg_restore.*--section=pre-data --section=data --jobs=3/
|
27
|
+
commands[3].must_match /pg_restore.* \/tmp\/dumps\/0001-faceburger_production\.schema/
|
28
|
+
|
29
|
+
# Assert import selective tables data
|
30
|
+
commands[4].must_match /COPY campaigns.*\/tmp\/dumps\/0002-campaigns\.table/
|
31
|
+
commands[5].must_match /COPY keywords.*\/tmp\/dumps\/0003-keywords\.table/
|
32
|
+
|
33
|
+
# Assert restore indexes, constraints, triggers and rules
|
34
|
+
commands[6].must_match /pg_restore.*--section=post-data --jobs=3/
|
35
|
+
commands[6].must_match /pg_restore.* \/tmp\/dumps\/0001-faceburger_production\.schema/
|
36
|
+
|
37
|
+
# Assert rename the temporary database to target restoring database name
|
38
|
+
commands[7].must_match /pg_terminate_backend.*faceburger_development/m
|
39
|
+
commands[8].must_match /DROP DATABASE IF EXISTS.*faceburger_development/
|
40
|
+
commands[9].must_match /ALTER DATABASE.*faceburger_development_\d+.*RENAME TO.*faceburger_development.*/
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
data/spec/spec_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: capistrano-db_sync
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.2
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Rafael Sales
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-03-09 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: capistrano
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 3.0.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 3.0.0
|
27
|
+
description: Fast download and restore dumps using edge features of Postgres 9.x
|
28
|
+
email:
|
29
|
+
- rafaelcds@gmail.com
|
30
|
+
executables: []
|
31
|
+
extensions: []
|
32
|
+
extra_rdoc_files: []
|
33
|
+
files:
|
34
|
+
- ".gitignore"
|
35
|
+
- Gemfile
|
36
|
+
- LICENSE
|
37
|
+
- README.md
|
38
|
+
- Rakefile
|
39
|
+
- capistrano-db_sync.gemspec
|
40
|
+
- lib/capistrano/db_sync.rb
|
41
|
+
- lib/capistrano/db_sync/configuration.rb
|
42
|
+
- lib/capistrano/db_sync/executor/base.rb
|
43
|
+
- lib/capistrano/db_sync/executor/local.rb
|
44
|
+
- lib/capistrano/db_sync/executor/remote.rb
|
45
|
+
- lib/capistrano/db_sync/postgres.rb
|
46
|
+
- lib/capistrano/db_sync/postgres/cli.rb
|
47
|
+
- lib/capistrano/db_sync/postgres/exporter.rb
|
48
|
+
- lib/capistrano/db_sync/postgres/file_name_generator.rb
|
49
|
+
- lib/capistrano/db_sync/postgres/importer.rb
|
50
|
+
- lib/capistrano/db_sync/tasks.rake
|
51
|
+
- lib/capistrano/db_sync/version.rb
|
52
|
+
- spec/lib/capistrano/db_sync/postgres/cli_spec.rb
|
53
|
+
- spec/lib/capistrano/db_sync/postgres/exporter_spec.rb
|
54
|
+
- spec/lib/capistrano/db_sync/postgres/importer_spec.rb
|
55
|
+
- spec/spec_helper.rb
|
56
|
+
homepage: https://github.com/rafaelsales/capistrano-db_sync
|
57
|
+
licenses:
|
58
|
+
- MIT
|
59
|
+
metadata: {}
|
60
|
+
post_install_message:
|
61
|
+
rdoc_options: []
|
62
|
+
require_paths:
|
63
|
+
- lib
|
64
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
70
|
+
requirements:
|
71
|
+
- - ">="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: '0'
|
74
|
+
requirements: []
|
75
|
+
rubyforge_project:
|
76
|
+
rubygems_version: 2.4.5
|
77
|
+
signing_key:
|
78
|
+
specification_version: 4
|
79
|
+
summary: A capistrano task to import remote Postgres databases
|
80
|
+
test_files:
|
81
|
+
- spec/lib/capistrano/db_sync/postgres/cli_spec.rb
|
82
|
+
- spec/lib/capistrano/db_sync/postgres/exporter_spec.rb
|
83
|
+
- spec/lib/capistrano/db_sync/postgres/importer_spec.rb
|
84
|
+
- spec/spec_helper.rb
|