aptible-cli 0.16.2 → 0.16.3
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 +4 -4
- data/README.md +1 -0
- data/lib/aptible/cli/agent.rb +62 -9
- data/lib/aptible/cli/helpers/database.rb +15 -0
- data/lib/aptible/cli/helpers/security_key.rb +136 -0
- data/lib/aptible/cli/helpers/system.rb +26 -0
- data/lib/aptible/cli/subcommands/db.rb +13 -0
- data/lib/aptible/cli/version.rb +1 -1
- data/spec/aptible/cli/agent_spec.rb +101 -9
- data/spec/aptible/cli/subcommands/db_spec.rb +56 -0
- metadata +5 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3eb92fffdc685a61fd06c0d5b38b485395eccb0e1762d8efeab0514e74c61b6b
|
4
|
+
data.tar.gz: 2237aacb034a1f7f7b1ae41328f0c05ee9393b7d48d6bdca93ca72072e867183
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c3ad2d2c88fa090eb8f65a9f4b91b1c3bccf180764a69ef6d60c3f068e37b6fafbbea515f9a6451b2983aa6e44dcd82bee1020003069e644c3389a2011207764
|
7
|
+
data.tar.gz: 63cc2f56d9f14f9c5981cae0df45d1de16bf03ee714ddb8635641176ad8f6315d1a00ca154b8ad377274a7494d8ca268e9873ea0b51eb1aa8186a1094f444ad6
|
data/README.md
CHANGED
@@ -48,6 +48,7 @@ Commands:
|
|
48
48
|
aptible db:execute HANDLE SQL_FILE [--on-error-stop] # Executes sql against a database
|
49
49
|
aptible db:list # List all databases
|
50
50
|
aptible db:reload HANDLE # Reload a database
|
51
|
+
aptible db:replicate HANDLE REPLICA_HANDLE [--container-size SIZE_MB] [--size SIZE_GB] # Create a replica/follower of a database
|
51
52
|
aptible db:restart HANDLE [--container-size SIZE_MB] [--size SIZE_GB] # Restart a database
|
52
53
|
aptible db:tunnel HANDLE # Create a local tunnel to a database
|
53
54
|
aptible db:url HANDLE # Display a database URL
|
data/lib/aptible/cli/agent.rb
CHANGED
@@ -15,6 +15,8 @@ require_relative 'helpers/app_or_database'
|
|
15
15
|
require_relative 'helpers/vhost'
|
16
16
|
require_relative 'helpers/vhost/option_set_builder'
|
17
17
|
require_relative 'helpers/tunnel'
|
18
|
+
require_relative 'helpers/system'
|
19
|
+
require_relative 'helpers/security_key'
|
18
20
|
|
19
21
|
require_relative 'subcommands/apps'
|
20
22
|
require_relative 'subcommands/config'
|
@@ -39,6 +41,7 @@ module Aptible
|
|
39
41
|
|
40
42
|
include Helpers::Token
|
41
43
|
include Helpers::Ssh
|
44
|
+
include Helpers::System
|
42
45
|
include Subcommands::Apps
|
43
46
|
include Subcommands::Config
|
44
47
|
include Subcommands::DB
|
@@ -83,8 +86,9 @@ module Aptible
|
|
83
86
|
option :otp_token, desc: 'A token generated by your second-factor app'
|
84
87
|
def login
|
85
88
|
email = options[:email] || ask('Email: ')
|
86
|
-
password = options[:password] ||
|
87
|
-
|
89
|
+
password = options[:password] || ask_then_line(
|
90
|
+
'Password: ', echo: false
|
91
|
+
)
|
88
92
|
|
89
93
|
token_options = { email: email, password: password }
|
90
94
|
|
@@ -93,7 +97,7 @@ module Aptible
|
|
93
97
|
|
94
98
|
begin
|
95
99
|
lifetime = '1w'
|
96
|
-
lifetime = '12h' if token_options[:otp_token]
|
100
|
+
lifetime = '12h' if token_options[:otp_token] || token_options[:u2f]
|
97
101
|
lifetime = options[:lifetime] if options[:lifetime]
|
98
102
|
|
99
103
|
duration = ChronicDuration.parse(lifetime)
|
@@ -104,14 +108,63 @@ module Aptible
|
|
104
108
|
token_options[:expires_in] = duration
|
105
109
|
token = Aptible::Auth::Token.create(token_options)
|
106
110
|
rescue OAuth2::Error => e
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
+
# If a MFA is require but a token wasn't provided,
|
112
|
+
# prompt the user for MFA authentication and retry
|
113
|
+
if e.code != 'otp_token_required'
|
114
|
+
raise Thor::Error, 'Could not authenticate with given ' \
|
115
|
+
"credentials: #{e.code}"
|
111
116
|
end
|
112
117
|
|
113
|
-
|
114
|
-
|
118
|
+
u2f = (e.response.parsed['exception_context'] || {})['u2f']
|
119
|
+
|
120
|
+
q = Queue.new
|
121
|
+
mfa_threads = []
|
122
|
+
|
123
|
+
# If the user has added a security key and their computer supports it,
|
124
|
+
# allow them to use it
|
125
|
+
if u2f && !which('u2f-host').nil?
|
126
|
+
origin = Aptible::Auth::Resource.new.get.href
|
127
|
+
app_id = Aptible::Auth::Resource.new.utf_trusted_facets.href
|
128
|
+
|
129
|
+
challenge = u2f.fetch('challenge')
|
130
|
+
|
131
|
+
devices = u2f.fetch('devices').map do |dev|
|
132
|
+
Helpers::SecurityKey::Device.new(
|
133
|
+
dev.fetch('version'), dev.fetch('key_handle')
|
134
|
+
)
|
135
|
+
end
|
136
|
+
|
137
|
+
puts 'Enter your 2FA token or touch your Security Key once it ' \
|
138
|
+
'starts blinking.'
|
139
|
+
|
140
|
+
mfa_threads << Thread.new do
|
141
|
+
token_options[:u2f] = Helpers::SecurityKey.authenticate(
|
142
|
+
origin, app_id, challenge, devices
|
143
|
+
)
|
144
|
+
|
145
|
+
puts ''
|
146
|
+
|
147
|
+
q.push(nil)
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
mfa_threads << Thread.new do
|
152
|
+
token_options[:otp_token] = options[:otp_token] || ask(
|
153
|
+
'2FA Token: '
|
154
|
+
)
|
155
|
+
|
156
|
+
q.push(nil)
|
157
|
+
end
|
158
|
+
|
159
|
+
# Block until one of the threads completes
|
160
|
+
q.pop
|
161
|
+
|
162
|
+
mfa_threads.each do |thr|
|
163
|
+
sleep 0.5 until thr.status != 'run'
|
164
|
+
thr.kill
|
165
|
+
end.each(&:join)
|
166
|
+
|
167
|
+
retry
|
115
168
|
end
|
116
169
|
|
117
170
|
save_token(token.access_token)
|
@@ -47,6 +47,21 @@ module Aptible
|
|
47
47
|
databases_from_handle(dest_handle, source.account).first
|
48
48
|
end
|
49
49
|
|
50
|
+
def replicate_database(source, dest_handle, options)
|
51
|
+
replication_params = {
|
52
|
+
type: 'replicate',
|
53
|
+
handle: dest_handle,
|
54
|
+
container_size: options[:container_size],
|
55
|
+
disk_size: options[:size]
|
56
|
+
}.reject { |_, v| v.nil? }
|
57
|
+
op = source.create_operation!(replication_params)
|
58
|
+
attach_to_operation_logs(op)
|
59
|
+
|
60
|
+
replica = databases_from_handle(dest_handle, source.account).first
|
61
|
+
attach_to_operation_logs(replica.operations.last)
|
62
|
+
replica
|
63
|
+
end
|
64
|
+
|
50
65
|
# Creates a local tunnel and yields the helper
|
51
66
|
|
52
67
|
def with_local_tunnel(credential, port = 0)
|
@@ -0,0 +1,136 @@
|
|
1
|
+
module Aptible
|
2
|
+
module CLI
|
3
|
+
module Helpers
|
4
|
+
module SecurityKey
|
5
|
+
U2F_LOGGER = Logger.new(
|
6
|
+
ENV['U2F_DEBUG'] ? STDERR : File.open(File::NULL, 'w')
|
7
|
+
)
|
8
|
+
|
9
|
+
class AuthenticatorParameters
|
10
|
+
attr_reader :origin, :challenge, :app_id, :version, :key_handle
|
11
|
+
attr_reader :request
|
12
|
+
|
13
|
+
def initialize(origin, challenge, app_id, device)
|
14
|
+
@origin = origin
|
15
|
+
@challenge = challenge
|
16
|
+
@app_id = app_id
|
17
|
+
@version = device.version
|
18
|
+
@key_handle = device.key_handle
|
19
|
+
|
20
|
+
@request = {
|
21
|
+
'challenge' => challenge,
|
22
|
+
'appId' => app_id,
|
23
|
+
'version' => version,
|
24
|
+
'keyHandle' => key_handle
|
25
|
+
}
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
class ThrottledAuthenticator
|
30
|
+
attr_reader :pid
|
31
|
+
|
32
|
+
def initialize(auth, pid)
|
33
|
+
@auth = auth
|
34
|
+
@pid = pid
|
35
|
+
end
|
36
|
+
|
37
|
+
def exited(_status)
|
38
|
+
[Authenticator.spawn(@auth), nil]
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.spawn(auth)
|
42
|
+
pid = Process.spawn(
|
43
|
+
'sleep', '2',
|
44
|
+
in: :close, out: :close, err: :close,
|
45
|
+
close_others: true
|
46
|
+
)
|
47
|
+
|
48
|
+
U2F_LOGGER.debug("#{self} #{auth.key_handle}: spawned #{pid}")
|
49
|
+
|
50
|
+
new(auth, pid)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
class Authenticator
|
55
|
+
attr_reader :pid
|
56
|
+
|
57
|
+
def initialize(auth, pid, out_read, err_read)
|
58
|
+
@auth = auth
|
59
|
+
@pid = pid
|
60
|
+
@out_read = out_read
|
61
|
+
@err_read = err_read
|
62
|
+
end
|
63
|
+
|
64
|
+
def exited(status)
|
65
|
+
out, err = [@out_read, @err_read].map(&:read).map(&:chomp)
|
66
|
+
|
67
|
+
if status.exitstatus == 0
|
68
|
+
U2F_LOGGER.info("#{self.class} #{@auth.key_handle}: ok: #{out}")
|
69
|
+
[nil, JSON.parse(out)]
|
70
|
+
else
|
71
|
+
U2F_LOGGER.warn("#{self.class} #{@auth.key_handle}: err: #{err}")
|
72
|
+
[ThrottledAuthenticator.spawn(@auth), nil]
|
73
|
+
end
|
74
|
+
ensure
|
75
|
+
[@out_read, @err_read].each(&:close)
|
76
|
+
end
|
77
|
+
|
78
|
+
def self.spawn(auth)
|
79
|
+
in_read, in_write = IO.pipe
|
80
|
+
out_read, out_write = IO.pipe
|
81
|
+
err_read, err_write = IO.pipe
|
82
|
+
|
83
|
+
pid = Process.spawn(
|
84
|
+
'u2f-host', '-aauthenticate', '-o', auth.origin,
|
85
|
+
in: in_read, out: out_write, err: err_write,
|
86
|
+
close_others: true
|
87
|
+
)
|
88
|
+
|
89
|
+
U2F_LOGGER.debug("#{self} #{auth.key_handle}: spawned #{pid}")
|
90
|
+
|
91
|
+
[in_read, out_write, err_write].each(&:close)
|
92
|
+
|
93
|
+
in_write.write(auth.request.to_json)
|
94
|
+
in_write.close
|
95
|
+
|
96
|
+
new(auth, pid, out_read, err_read)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
class Device
|
101
|
+
attr_reader :version, :key_handle
|
102
|
+
|
103
|
+
def initialize(version, key_handle)
|
104
|
+
@version = version
|
105
|
+
@key_handle = key_handle
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def self.authenticate(origin, app_id, challenge, devices)
|
110
|
+
procs = Hash[devices.map do |device|
|
111
|
+
params = AuthenticatorParameters.new(
|
112
|
+
origin, challenge, app_id, device
|
113
|
+
)
|
114
|
+
w = Authenticator.spawn(params)
|
115
|
+
[w.pid, w]
|
116
|
+
end]
|
117
|
+
|
118
|
+
begin
|
119
|
+
loop do
|
120
|
+
pid, status = Process.wait2
|
121
|
+
w = procs.delete(pid)
|
122
|
+
raise "waited unknown pid: #{pid}" if w.nil?
|
123
|
+
|
124
|
+
r, out = w.exited(status)
|
125
|
+
|
126
|
+
procs[r.pid] = r if r
|
127
|
+
return out if out
|
128
|
+
end
|
129
|
+
ensure
|
130
|
+
procs.values.map(&:pid).each { |p| Process.kill(:SIGTERM, p) }
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Aptible
|
2
|
+
module CLI
|
3
|
+
module Helpers
|
4
|
+
module System
|
5
|
+
def which(cmd)
|
6
|
+
exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']
|
7
|
+
|
8
|
+
ENV['PATH'].split(File::PATH_SEPARATOR).each do |path|
|
9
|
+
exts.each do |ext|
|
10
|
+
exe = File.join(path, "#{cmd}#{ext}")
|
11
|
+
return exe if File.executable?(exe) && !File.directory?(exe)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
nil
|
16
|
+
end
|
17
|
+
|
18
|
+
def ask_then_line(*args)
|
19
|
+
ret = ask(*args)
|
20
|
+
puts ''
|
21
|
+
ret
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -114,6 +114,19 @@ module Aptible
|
|
114
114
|
render_database(database, database.account)
|
115
115
|
end
|
116
116
|
|
117
|
+
desc 'db:replicate HANDLE REPLICA_HANDLE ' \
|
118
|
+
'[--container-size SIZE_MB] [--size SIZE_GB]',
|
119
|
+
'Create a replica/follower of a database'
|
120
|
+
option :environment
|
121
|
+
option :container_size, type: :numeric
|
122
|
+
option :size, type: :numeric
|
123
|
+
define_method 'db:replicate' do |source_handle, dest_handle|
|
124
|
+
source = ensure_database(options.merge(db: source_handle))
|
125
|
+
CLI.logger.info "Replicating #{source_handle}..."
|
126
|
+
database = replicate_database(source, dest_handle, options)
|
127
|
+
render_database(database.reload, database.account)
|
128
|
+
end
|
129
|
+
|
117
130
|
desc 'db:dump HANDLE [pg_dump options]',
|
118
131
|
'Dump a remote database to file'
|
119
132
|
option :environment
|
data/lib/aptible/cli/version.rb
CHANGED
@@ -30,10 +30,15 @@ describe Aptible::CLI::Agent do
|
|
30
30
|
let(:created_at) { Time.now }
|
31
31
|
let(:expires_at) { created_at + 1.week }
|
32
32
|
|
33
|
-
|
34
|
-
|
35
|
-
|
33
|
+
def make_oauth2_error(code, ctx = nil)
|
34
|
+
parsed = { 'error' => code }
|
35
|
+
parsed['exception_context'] = ctx if ctx
|
36
|
+
response = double('response', parsed: parsed, body: "error #{code}")
|
37
|
+
allow(response).to receive(:error=)
|
38
|
+
OAuth2::Error.new(response)
|
39
|
+
end
|
36
40
|
|
41
|
+
before do
|
37
42
|
allow(token).to receive(:access_token).and_return 'access_token'
|
38
43
|
allow(token).to receive(:created_at).and_return created_at
|
39
44
|
allow(token).to receive(:expires_at).and_return expires_at
|
@@ -55,7 +60,7 @@ describe Aptible::CLI::Agent do
|
|
55
60
|
|
56
61
|
it 'should raise an error if authentication fails' do
|
57
62
|
allow(Aptible::Auth::Token).to receive(:create)
|
58
|
-
.and_raise(
|
63
|
+
.and_raise(make_oauth2_error('foo'))
|
59
64
|
expect do
|
60
65
|
subject.login
|
61
66
|
end.to raise_error 'Could not authenticate with given credentials: foo'
|
@@ -87,7 +92,7 @@ describe Aptible::CLI::Agent do
|
|
87
92
|
expect { subject.login }.to raise_error(/Invalid token lifetime/)
|
88
93
|
end
|
89
94
|
|
90
|
-
context 'with
|
95
|
+
context 'with 2FA' do
|
91
96
|
let(:email) { 'foo@example.org' }
|
92
97
|
let(:password) { 'bar' }
|
93
98
|
let(:token) { '123456' }
|
@@ -124,7 +129,7 @@ describe Aptible::CLI::Agent do
|
|
124
129
|
expect(Aptible::Auth::Token).to receive(:create)
|
125
130
|
.with(email: email, password: password, expires_in: 1.week.seconds)
|
126
131
|
.once
|
127
|
-
.and_raise(
|
132
|
+
.and_raise(make_oauth2_error('otp_token_required'))
|
128
133
|
|
129
134
|
expect(Aptible::Auth::Token).to receive(:create)
|
130
135
|
.with(email: email, password: password, otp_token: token,
|
@@ -139,7 +144,7 @@ describe Aptible::CLI::Agent do
|
|
139
144
|
expect(Aptible::Auth::Token).to receive(:create)
|
140
145
|
.with(email: email, password: password, expires_in: 1.day.seconds)
|
141
146
|
.once
|
142
|
-
.and_raise(
|
147
|
+
.and_raise(make_oauth2_error('otp_token_required'))
|
143
148
|
|
144
149
|
expect(Aptible::Auth::Token).to receive(:create)
|
145
150
|
.with(email: email, password: password, otp_token: token,
|
@@ -155,17 +160,104 @@ describe Aptible::CLI::Agent do
|
|
155
160
|
expect(Aptible::Auth::Token).to receive(:create)
|
156
161
|
.with(email: email, password: password, expires_in: 1.week.seconds)
|
157
162
|
.once
|
158
|
-
.and_raise(
|
163
|
+
.and_raise(make_oauth2_error('otp_token_required'))
|
159
164
|
|
160
165
|
expect(Aptible::Auth::Token).to receive(:create)
|
161
166
|
.with(email: email, password: password, otp_token: token,
|
162
167
|
expires_in: 12.hours.seconds)
|
163
168
|
.once
|
164
|
-
.and_raise(
|
169
|
+
.and_raise(make_oauth2_error('foo'))
|
165
170
|
|
166
171
|
expect { subject.login }.to raise_error(/Could not authenticate/)
|
167
172
|
end
|
168
173
|
end
|
174
|
+
|
175
|
+
context 'with U2F' do
|
176
|
+
before do
|
177
|
+
allow(subject).to receive(:options)
|
178
|
+
.and_return(email: email, password: password)
|
179
|
+
end
|
180
|
+
|
181
|
+
it 'shouldn\'t use U2F if not supported' do
|
182
|
+
allow(subject).to receive(:which)
|
183
|
+
.and_return(nil)
|
184
|
+
|
185
|
+
e = make_oauth2_error(
|
186
|
+
'otp_token_required',
|
187
|
+
'u2f' => {
|
188
|
+
'challenge' => 'some 123',
|
189
|
+
'devices' => [
|
190
|
+
{ 'version' => 'U2F_V2', 'key_handle' => '123' },
|
191
|
+
{ 'version' => 'U2F_V2', 'key_handle' => '456' }
|
192
|
+
]
|
193
|
+
}
|
194
|
+
)
|
195
|
+
|
196
|
+
expect(Aptible::Auth::Token).to receive(:create)
|
197
|
+
.with(email: email, password: password, expires_in: 1.week.seconds)
|
198
|
+
.once
|
199
|
+
.and_raise(e)
|
200
|
+
|
201
|
+
expect(Aptible::CLI::Helpers::SecurityKey).not_to \
|
202
|
+
receive(:authenticate)
|
203
|
+
|
204
|
+
expect(subject).to receive(:ask).with('2FA Token: ')
|
205
|
+
.once
|
206
|
+
.and_return(token)
|
207
|
+
|
208
|
+
expect(Aptible::Auth::Token).to receive(:create)
|
209
|
+
.with(email: email, password: password, otp_token: token,
|
210
|
+
expires_in: 12.hours.seconds)
|
211
|
+
.once
|
212
|
+
.and_return(token)
|
213
|
+
|
214
|
+
subject.login
|
215
|
+
end
|
216
|
+
|
217
|
+
it 'should call into U2F if supported' do
|
218
|
+
allow(subject).to receive(:which).and_return('u2f-host')
|
219
|
+
allow(subject).to receive(:ask).with('2FA Token: ') { sleep }
|
220
|
+
|
221
|
+
e = make_oauth2_error(
|
222
|
+
'otp_token_required',
|
223
|
+
'u2f' => {
|
224
|
+
'challenge' => 'some 123',
|
225
|
+
'devices' => [
|
226
|
+
{ 'version' => 'U2F_V2', 'key_handle' => '123' },
|
227
|
+
{ 'version' => 'U2F_V2', 'key_handle' => '456' }
|
228
|
+
]
|
229
|
+
}
|
230
|
+
)
|
231
|
+
|
232
|
+
u2f = double('u2f response')
|
233
|
+
|
234
|
+
expect(Aptible::Auth::Token).to receive(:create)
|
235
|
+
.with(email: email, password: password, expires_in: 1.week.seconds)
|
236
|
+
.once
|
237
|
+
.and_raise(e)
|
238
|
+
|
239
|
+
expect(subject).to receive(:puts).with(/security key/i)
|
240
|
+
|
241
|
+
expect(Aptible::CLI::Helpers::SecurityKey).to receive(:authenticate)
|
242
|
+
.with(
|
243
|
+
'https://auth.aptible.com/',
|
244
|
+
'https://auth.aptible.com/u2f/trusted_facets',
|
245
|
+
'some 123',
|
246
|
+
array_including(
|
247
|
+
instance_of(Aptible::CLI::Helpers::SecurityKey::Device),
|
248
|
+
instance_of(Aptible::CLI::Helpers::SecurityKey::Device)
|
249
|
+
)
|
250
|
+
).and_return(u2f)
|
251
|
+
|
252
|
+
expect(Aptible::Auth::Token).to receive(:create)
|
253
|
+
.with(email: email, password: password, u2f: u2f,
|
254
|
+
expires_in: 12.hours.seconds)
|
255
|
+
.once
|
256
|
+
.and_return(token)
|
257
|
+
|
258
|
+
subject.login
|
259
|
+
end
|
260
|
+
end
|
169
261
|
end
|
170
262
|
end
|
171
263
|
|
@@ -439,6 +439,62 @@ describe Aptible::CLI::Agent do
|
|
439
439
|
end
|
440
440
|
end
|
441
441
|
|
442
|
+
describe '#db:replicate' do
|
443
|
+
let(:databases) { [] }
|
444
|
+
before { allow(Aptible::Api::Database).to receive(:all) { databases } }
|
445
|
+
|
446
|
+
def expect_replicate_database(opts = {})
|
447
|
+
master = Fabricate(:database, handle: 'master')
|
448
|
+
databases << master
|
449
|
+
replica = Fabricate(:database,
|
450
|
+
account: master.account,
|
451
|
+
handle: 'replica')
|
452
|
+
|
453
|
+
op = Fabricate(:operation)
|
454
|
+
|
455
|
+
params = { type: 'replicate', handle: 'replica' }.merge(opts)
|
456
|
+
params[:disk_size] = params.delete(:size) if params[:size]
|
457
|
+
expect(master).to receive(:create_operation!)
|
458
|
+
.with(**params).and_return(op)
|
459
|
+
|
460
|
+
expect(subject).to receive(:attach_to_operation_logs).with(op) do
|
461
|
+
databases << replica
|
462
|
+
replica
|
463
|
+
end
|
464
|
+
|
465
|
+
provision = Fabricate(:operation)
|
466
|
+
|
467
|
+
expect(replica).to receive_message_chain(:operations, :last)
|
468
|
+
.and_return(provision)
|
469
|
+
|
470
|
+
expect(subject).to receive(:attach_to_operation_logs).with(provision)
|
471
|
+
|
472
|
+
expect(replica).to receive(:reload).and_return(replica)
|
473
|
+
|
474
|
+
subject.options = opts
|
475
|
+
subject.send('db:replicate', 'master', 'replica')
|
476
|
+
|
477
|
+
expect(captured_logs).to match(/replicating master/i)
|
478
|
+
end
|
479
|
+
|
480
|
+
it 'allows replicating an existing database' do
|
481
|
+
expect_replicate_database
|
482
|
+
end
|
483
|
+
|
484
|
+
it 'allows replicating a database with a container size' do
|
485
|
+
expect_replicate_database(container_size: 40)
|
486
|
+
end
|
487
|
+
|
488
|
+
it 'allows replicating a database with a disk size' do
|
489
|
+
expect_replicate_database(size: 40)
|
490
|
+
end
|
491
|
+
|
492
|
+
it 'fails if the DB is not found' do
|
493
|
+
expect { subject.send('db:replicate', 'nope', 'replica') }
|
494
|
+
.to raise_error(Thor::Error, 'Could not find database nope')
|
495
|
+
end
|
496
|
+
end
|
497
|
+
|
442
498
|
describe '#db:dump' do
|
443
499
|
it 'should fail if database is non-existent' do
|
444
500
|
allow(Aptible::Api::Database).to receive(:all) { [] }
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: aptible-cli
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.16.
|
4
|
+
version: 0.16.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Frank Macreery
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2019-
|
11
|
+
date: 2019-10-24 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: aptible-resource
|
@@ -277,7 +277,9 @@ files:
|
|
277
277
|
- lib/aptible/cli/helpers/database.rb
|
278
278
|
- lib/aptible/cli/helpers/environment.rb
|
279
279
|
- lib/aptible/cli/helpers/operation.rb
|
280
|
+
- lib/aptible/cli/helpers/security_key.rb
|
280
281
|
- lib/aptible/cli/helpers/ssh.rb
|
282
|
+
- lib/aptible/cli/helpers/system.rb
|
281
283
|
- lib/aptible/cli/helpers/token.rb
|
282
284
|
- lib/aptible/cli/helpers/tunnel.rb
|
283
285
|
- lib/aptible/cli/helpers/vhost.rb
|
@@ -373,7 +375,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
373
375
|
version: '0'
|
374
376
|
requirements: []
|
375
377
|
rubyforge_project:
|
376
|
-
rubygems_version: 2.7.6
|
378
|
+
rubygems_version: 2.7.6.2
|
377
379
|
signing_key:
|
378
380
|
specification_version: 4
|
379
381
|
summary: Command-line interface for Aptible services
|