duple 0.0.1
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/.gitignore +19 -0
- data/.rspec +2 -0
- data/.simplecov +14 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +64 -0
- data/Rakefile +21 -0
- data/bin/duple +11 -0
- data/duple.gemspec +30 -0
- data/lib/duple.rb +3 -0
- data/lib/duple/cli/config.rb +63 -0
- data/lib/duple/cli/copy.rb +29 -0
- data/lib/duple/cli/helpers.rb +192 -0
- data/lib/duple/cli/init.rb +15 -0
- data/lib/duple/cli/refresh.rb +75 -0
- data/lib/duple/cli/root.rb +41 -0
- data/lib/duple/cli/structure.rb +23 -0
- data/lib/duple/configuration.rb +203 -0
- data/lib/duple/heroku_runner.rb +25 -0
- data/lib/duple/pg_runner.rb +27 -0
- data/lib/duple/runner.rb +63 -0
- data/lib/duple/version.rb +3 -0
- data/spec/duple/cli/config_spec.rb +16 -0
- data/spec/duple/cli/copy_spec.rb +112 -0
- data/spec/duple/cli/init_spec.rb +19 -0
- data/spec/duple/cli/refresh_spec.rb +413 -0
- data/spec/duple/cli/structure_spec.rb +133 -0
- data/spec/duple/configuration_spec.rb +166 -0
- data/spec/duple/runner_spec.rb +65 -0
- data/spec/spec_helper.rb +18 -0
- data/spec/support/cli_spec_helpers.rb +85 -0
- data/spec/support/duple.rb +5 -0
- data/spec/support/io_spec_helpers.rb +33 -0
- data/spec/support/rspec_fire.rb +0 -0
- metadata +190 -0
@@ -0,0 +1,133 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Duple::CLI::Structure do
|
4
|
+
include Duple::CLISpecHelpers
|
5
|
+
|
6
|
+
def invoke_structure(options = nil)
|
7
|
+
invoke_cli(:structure, options)
|
8
|
+
end
|
9
|
+
|
10
|
+
context 'from heroku to local' do
|
11
|
+
before {
|
12
|
+
stub_fetch_config
|
13
|
+
stub_dump_structure
|
14
|
+
stub_reset_local
|
15
|
+
stub_restore_structure
|
16
|
+
}
|
17
|
+
|
18
|
+
let(:source) { 'stage' }
|
19
|
+
let(:target) { 'development' }
|
20
|
+
|
21
|
+
it 'fetches the source credentials' do
|
22
|
+
runner.should_receive(:capture).with("heroku config -a duple-stage")
|
23
|
+
.and_return(heroku_config_response)
|
24
|
+
|
25
|
+
invoke_structure
|
26
|
+
end
|
27
|
+
|
28
|
+
it 'dumps the structure from the source' do
|
29
|
+
runner.should_receive(:run)
|
30
|
+
.with(%{PGPASSWORD="pg-pass" pg_dump -Fc --no-acl -O -s -h pg-host -U pg-user -p 6022 pg-db > tmp/duple/stage-structure.dump})
|
31
|
+
|
32
|
+
invoke_structure
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'resets the local database' do
|
36
|
+
runner.should_receive(:run).with(%{bundle exec rake db:drop db:create})
|
37
|
+
|
38
|
+
invoke_structure
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'loads the structure into the local database' do
|
42
|
+
runner.should_receive(:run)
|
43
|
+
.with(%{PGPASSWORD="" pg_restore -v --no-acl -O -s -h localhost -U postgres -p 5432 -d duple_development < tmp/duple/stage-structure.dump})
|
44
|
+
|
45
|
+
invoke_structure
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
context 'from heroku to heroku' do
|
50
|
+
before {
|
51
|
+
stub_fetch_config
|
52
|
+
stub_dump_structure
|
53
|
+
stub_reset_heroku
|
54
|
+
stub_restore_structure
|
55
|
+
}
|
56
|
+
|
57
|
+
let(:source) { 'production' }
|
58
|
+
let(:target) { 'stage' }
|
59
|
+
|
60
|
+
it 'fetches the source credentials' do
|
61
|
+
runner.should_receive(:capture).with("heroku config -a duple-production")
|
62
|
+
.and_return(heroku_config_response)
|
63
|
+
|
64
|
+
invoke_structure
|
65
|
+
end
|
66
|
+
|
67
|
+
it 'fetches the target credentials' do
|
68
|
+
runner.should_receive(:capture).with("heroku config -a duple-stage")
|
69
|
+
.and_return(heroku_config_response)
|
70
|
+
|
71
|
+
invoke_structure
|
72
|
+
end
|
73
|
+
|
74
|
+
it 'dumps the structure from the source' do
|
75
|
+
runner.should_receive(:run)
|
76
|
+
.with(%{PGPASSWORD="pg-pass" pg_dump -Fc --no-acl -O -s -h pg-host -U pg-user -p 6022 pg-db > tmp/duple/production-structure.dump})
|
77
|
+
|
78
|
+
invoke_structure
|
79
|
+
end
|
80
|
+
|
81
|
+
it 'resets the target database' do
|
82
|
+
runner.should_receive(:run).with(%{heroku pg:reset -a duple-stage})
|
83
|
+
|
84
|
+
invoke_structure
|
85
|
+
end
|
86
|
+
|
87
|
+
it 'loads the structure into the target database' do
|
88
|
+
runner.should_receive(:run)
|
89
|
+
.with(%{PGPASSWORD="pg-pass" pg_restore -v --no-acl -O -s -h pg-host -U pg-user -p 6022 -d pg-db < tmp/duple/production-structure.dump})
|
90
|
+
|
91
|
+
invoke_structure
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
context 'from local to heroku' do
|
96
|
+
before {
|
97
|
+
stub_fetch_config
|
98
|
+
stub_dump_structure
|
99
|
+
stub_reset_heroku
|
100
|
+
stub_restore_structure
|
101
|
+
}
|
102
|
+
|
103
|
+
let(:source) { 'development' }
|
104
|
+
let(:target) { 'stage' }
|
105
|
+
|
106
|
+
it 'fetches the target credentials' do
|
107
|
+
runner.should_receive(:capture).with("heroku config -a duple-stage")
|
108
|
+
.and_return(heroku_config_response)
|
109
|
+
|
110
|
+
invoke_structure
|
111
|
+
end
|
112
|
+
|
113
|
+
it 'dumps the structure from the local database' do
|
114
|
+
runner.should_receive(:run)
|
115
|
+
.with(%{PGPASSWORD="" pg_dump -Fc --no-acl -O -s -h localhost -U postgres -p 5432 duple_development > tmp/duple/development-structure.dump})
|
116
|
+
|
117
|
+
invoke_structure
|
118
|
+
end
|
119
|
+
|
120
|
+
it 'resets the target database' do
|
121
|
+
runner.should_receive(:run).with(%{heroku pg:reset -a duple-stage})
|
122
|
+
|
123
|
+
invoke_structure
|
124
|
+
end
|
125
|
+
|
126
|
+
it 'loads the structure into the target database' do
|
127
|
+
runner.should_receive(:run)
|
128
|
+
.with(%{PGPASSWORD="pg-pass" pg_restore -v --no-acl -O -s -h pg-host -U pg-user -p 6022 -d pg-db < tmp/duple/development-structure.dump})
|
129
|
+
|
130
|
+
invoke_structure
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
@@ -0,0 +1,166 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Duple::Configuration do
|
4
|
+
|
5
|
+
describe '#excluded_tables' do
|
6
|
+
let(:config_hash) { YAML.load(File.read('spec/config/groups.yml'))}
|
7
|
+
|
8
|
+
context 'with neither tables nor group options' do
|
9
|
+
it 'returns an empty array' do
|
10
|
+
config = Duple::Configuration.new(config_hash, {})
|
11
|
+
config.excluded_tables.should == []
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
context 'with the group option' do
|
16
|
+
it 'returns the tables specified by the group' do
|
17
|
+
config = Duple::Configuration.new(config_hash, {group: 'no_comments'})
|
18
|
+
config.excluded_tables.should == ['comments']
|
19
|
+
end
|
20
|
+
|
21
|
+
context 'with an include_all group' do
|
22
|
+
it 'returns the tables specified by the group' do
|
23
|
+
config = Duple::Configuration.new(config_hash, {group: 'all_but_comments'})
|
24
|
+
config.excluded_tables.should == ['comments']
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
context 'with both tables and group options' do
|
30
|
+
it 'does not exclude tables in the tables option' do
|
31
|
+
config = Duple::Configuration.new(config_hash, {
|
32
|
+
group: 'no_comments',
|
33
|
+
tables: ['comments']
|
34
|
+
})
|
35
|
+
config.included_tables.should == ['comments']
|
36
|
+
config.excluded_tables.should == []
|
37
|
+
end
|
38
|
+
|
39
|
+
context 'with an include_all group' do
|
40
|
+
it 'does not exclude tables in the tables option' do
|
41
|
+
config = Duple::Configuration.new(config_hash, {
|
42
|
+
group: 'all_but_comments',
|
43
|
+
tables: ['comments']
|
44
|
+
})
|
45
|
+
config.included_tables.should == []
|
46
|
+
config.excluded_tables.should == []
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
describe '#included_tables' do
|
53
|
+
let(:config_hash) { YAML.load(File.read('spec/config/groups.yml'))}
|
54
|
+
|
55
|
+
context 'with neither tables nor group options' do
|
56
|
+
it 'returns an empty array' do
|
57
|
+
config = Duple::Configuration.new(config_hash, {})
|
58
|
+
config.included_tables.should == []
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
context 'with the tables option' do
|
63
|
+
it 'returns the tables specified in the option value' do
|
64
|
+
config = Duple::Configuration.new(config_hash, {tables: ['categories']})
|
65
|
+
config.included_tables.should == ['categories']
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
context 'with the group option' do
|
70
|
+
it 'returns the tables specified by the group' do
|
71
|
+
config = Duple::Configuration.new(config_hash, {group: 'minimal'})
|
72
|
+
config.included_tables.should == ['categories', 'links']
|
73
|
+
end
|
74
|
+
|
75
|
+
context 'with an include_all group' do
|
76
|
+
it 'returns an empty array' do
|
77
|
+
config = Duple::Configuration.new(config_hash, {group: 'all'})
|
78
|
+
config.included_tables.should == []
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
context 'with both tables and group options' do
|
84
|
+
it 'returns all tables in the group and the option value' do
|
85
|
+
config = Duple::Configuration.new(config_hash, {
|
86
|
+
group: 'minimal',
|
87
|
+
tables: ['posts', 'comments']
|
88
|
+
})
|
89
|
+
config.included_tables.should == ['categories', 'comments', 'links', 'posts']
|
90
|
+
end
|
91
|
+
|
92
|
+
context 'with an include_all group' do
|
93
|
+
it 'returns an empty array' do
|
94
|
+
config = Duple::Configuration.new(config_hash, {
|
95
|
+
group: 'all',
|
96
|
+
tables: ['posts', 'comments']
|
97
|
+
})
|
98
|
+
config.included_tables.should == []
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
describe '#source_environment' do
|
105
|
+
let(:config_hash) { YAML.load(File.read('spec/config/simple.yml'))}
|
106
|
+
|
107
|
+
it 'gets the default source environment' do
|
108
|
+
config = Duple::Configuration.new(config_hash, {})
|
109
|
+
config.source_environment.should_not be_nil
|
110
|
+
config.source_environment['appname'].should == 'duple-stage'
|
111
|
+
end
|
112
|
+
|
113
|
+
it 'gets the source environment' do
|
114
|
+
config = Duple::Configuration.new(config_hash, { source: 'production' })
|
115
|
+
config.source_environment.should_not be_nil
|
116
|
+
config.source_environment['appname'].should == 'duple-production'
|
117
|
+
end
|
118
|
+
|
119
|
+
it 'does not allow multiple default sources' do
|
120
|
+
config_hash['environments']['backstage'] = {'default_source' => true}
|
121
|
+
config = Duple::Configuration.new(config_hash, {})
|
122
|
+
expect {
|
123
|
+
config.source_environment
|
124
|
+
}.to raise_error(ArgumentError, 'Only a single environment can be default_source.')
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
describe '#target_environment' do
|
129
|
+
let(:config_hash) { YAML.load(File.read('spec/config/simple.yml'))}
|
130
|
+
|
131
|
+
it 'gets the default target environment' do
|
132
|
+
config = Duple::Configuration.new(config_hash, {})
|
133
|
+
config.target_environment.should_not be_nil
|
134
|
+
config.target_environment['type'].should == 'local'
|
135
|
+
end
|
136
|
+
|
137
|
+
it 'gets the target environment' do
|
138
|
+
config = Duple::Configuration.new(config_hash, { target: 'stage' })
|
139
|
+
config.target_environment.should_not be_nil
|
140
|
+
config.target_environment['appname'].should == 'duple-stage'
|
141
|
+
end
|
142
|
+
|
143
|
+
it 'fails if the target is not allowed' do
|
144
|
+
config = Duple::Configuration.new(config_hash, { target: 'production' })
|
145
|
+
expect {
|
146
|
+
config.target_environment
|
147
|
+
}.to raise_error(ArgumentError, 'Invalid target: production is not allowed to be a target.')
|
148
|
+
end
|
149
|
+
|
150
|
+
it 'does not allow multiple default targets' do
|
151
|
+
config_hash['environments']['backstage'] = {'default_target' => true}
|
152
|
+
config = Duple::Configuration.new(config_hash, {})
|
153
|
+
expect {
|
154
|
+
config.target_environment
|
155
|
+
}.to raise_error(ArgumentError, 'Only a single environment can be default_target.')
|
156
|
+
end
|
157
|
+
|
158
|
+
it 'allows multiple disallowed targets' do
|
159
|
+
config_hash['environments']['reporting'] = {'allow_target' => false}
|
160
|
+
config = Duple::Configuration.new(config_hash, {})
|
161
|
+
expect {
|
162
|
+
config.target_environment
|
163
|
+
}.to_not raise_error(ArgumentError, 'Only a single environment can be allow_target.')
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'fileutils'
|
3
|
+
|
4
|
+
describe Duple::Runner do
|
5
|
+
|
6
|
+
let(:test_dir) { 'tmp' }
|
7
|
+
let(:test_file) { "#{test_dir}/spawnbag.txt" }
|
8
|
+
let(:log) { StringIO.new }
|
9
|
+
after { log.close }
|
10
|
+
|
11
|
+
before { FileUtils.mkdir_p(test_dir) }
|
12
|
+
after { FileUtils.rm(test_file) if File.exists?(test_file) }
|
13
|
+
|
14
|
+
context 'in live mode' do
|
15
|
+
let(:runner) { Duple::Runner.new(log: log) }
|
16
|
+
|
17
|
+
it 'executes a command' do
|
18
|
+
runner.run("touch #{test_file}")
|
19
|
+
|
20
|
+
Pathname.new(test_file).should exist
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'captures the output of the command' do
|
24
|
+
result = runner.capture("cat #{__FILE__}")
|
25
|
+
result.should =~ /captures the output of the command/
|
26
|
+
end
|
27
|
+
|
28
|
+
it 'writes the commands to the log' do
|
29
|
+
runner.run("touch #{test_file}")
|
30
|
+
log.string.should == " * Running: touch tmp/spawnbag.txt\n"
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'raises an error if the command fails' do
|
34
|
+
expect {
|
35
|
+
runner.capture("cat NOT_A_REAL_FILE > /dev/null 2>&1")
|
36
|
+
}.to raise_error(RuntimeError, /Command failed: pid \d+ exit 1/)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
context 'in dry run mode' do
|
41
|
+
let(:runner) { Duple::Runner.new(dry_run: true, log: log) }
|
42
|
+
|
43
|
+
it 'does not actually run the commands' do
|
44
|
+
runner.run("touch #{test_file}")
|
45
|
+
|
46
|
+
Pathname.new(test_file).should_not exist
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'writes the commands to the log' do
|
50
|
+
runner.run("touch #{test_file}")
|
51
|
+
log.string.should == " * Running: touch tmp/spawnbag.txt\n"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
context 'with a recorder' do
|
56
|
+
let(:recorder) { StringIO.new }
|
57
|
+
let(:runner) { Duple::Runner.new(recorder: recorder, log: log) }
|
58
|
+
|
59
|
+
it 'logs commands to a file' do
|
60
|
+
runner.capture('ls tmp')
|
61
|
+
runner.capture('date')
|
62
|
+
recorder.string.split("\n").should == ['ls tmp', 'date']
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
lib_path = File.expand_path('../../lib', __FILE__)
|
2
|
+
$:.unshift(lib_path)
|
3
|
+
|
4
|
+
require 'simplecov'
|
5
|
+
require 'duple'
|
6
|
+
Dir['spec/support/**/*.rb'].each { |f| require File.expand_path(f) }
|
7
|
+
|
8
|
+
RSpec.configure do |config|
|
9
|
+
config.treat_symbols_as_metadata_keys_with_true_values = true
|
10
|
+
config.run_all_when_everything_filtered = true
|
11
|
+
config.filter_run :focus
|
12
|
+
|
13
|
+
# Run specs in random order to surface order dependencies. If you find an
|
14
|
+
# order dependency and want to debug it, you can fix the order by providing
|
15
|
+
# the seed, which is printed after each run.
|
16
|
+
# --seed 1234
|
17
|
+
config.order = 'random'
|
18
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
module Duple
|
2
|
+
module CLISpecHelpers
|
3
|
+
def self.included(base)
|
4
|
+
base.send(:let, :runner) { double_runner }
|
5
|
+
base.send(:let, :source) { 'stage' }
|
6
|
+
base.send(:let, :target) { 'development' }
|
7
|
+
base.send(:let, :snapshot_path) { 'tmp/duple/stage-2012-10-19-03-09-30.dump' }
|
8
|
+
end
|
9
|
+
|
10
|
+
def double_runner
|
11
|
+
runner = fire_double('Duple::Runner')
|
12
|
+
Duple::Runner.stub(:new).and_return(runner)
|
13
|
+
runner
|
14
|
+
end
|
15
|
+
|
16
|
+
def heroku_pgbackups_url_response
|
17
|
+
File.read('spec/config/heroku_pgbackups_url.txt')
|
18
|
+
end
|
19
|
+
|
20
|
+
def heroku_pgbackups_response
|
21
|
+
File.read('spec/config/heroku_pgbackups.txt')
|
22
|
+
end
|
23
|
+
|
24
|
+
def heroku_config_response
|
25
|
+
File.read('spec/config/heroku_config.txt')
|
26
|
+
end
|
27
|
+
|
28
|
+
def stub_fetch_url
|
29
|
+
runner.stub(:capture).with(/heroku pgbackups:url/)
|
30
|
+
.and_return(heroku_pgbackups_url_response)
|
31
|
+
end
|
32
|
+
|
33
|
+
def stub_fetch_config
|
34
|
+
runner.stub(:capture).with(/heroku config/)
|
35
|
+
.and_return(heroku_config_response)
|
36
|
+
end
|
37
|
+
|
38
|
+
def stub_fetch_backups
|
39
|
+
runner.stub(:capture).with(/heroku pgbackups /)
|
40
|
+
.and_return(heroku_pgbackups_response)
|
41
|
+
end
|
42
|
+
|
43
|
+
def stub_download_snapshot
|
44
|
+
runner.stub(:run).with(/curl/)
|
45
|
+
end
|
46
|
+
|
47
|
+
def stub_dump_structure
|
48
|
+
runner.stub(:run).with(/pg_dump .* -s /)
|
49
|
+
end
|
50
|
+
|
51
|
+
def stub_restore_structure
|
52
|
+
runner.stub(:run).with(/pg_restore .* -s /)
|
53
|
+
end
|
54
|
+
|
55
|
+
def stub_dump_data
|
56
|
+
runner.stub(:run).with(/pg_dump .* -a /)
|
57
|
+
end
|
58
|
+
|
59
|
+
def stub_restore_url
|
60
|
+
runner.stub(:run).with(/heroku pgbackups:restore .* -a /)
|
61
|
+
end
|
62
|
+
|
63
|
+
def stub_restore_data
|
64
|
+
runner.stub(:run).with(/pg_restore .* -a /)
|
65
|
+
end
|
66
|
+
|
67
|
+
def stub_reset_heroku
|
68
|
+
runner.stub(:run).with(/heroku pg:reset/)
|
69
|
+
end
|
70
|
+
|
71
|
+
def stub_reset_local
|
72
|
+
runner.stub(:run).with(/bundle exec rake db:drop db:create/)
|
73
|
+
end
|
74
|
+
|
75
|
+
def invoke_cli(command, options = nil)
|
76
|
+
options ||= {}
|
77
|
+
script = Duple::CLI::Root.new
|
78
|
+
script.invoke(command, [], {
|
79
|
+
config: 'spec/config/simple.yml',
|
80
|
+
source: source,
|
81
|
+
target: target
|
82
|
+
}.merge(options))
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|