aptible-cli 0.16.2 → 0.16.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c50391308952a422d84f497808c25fbaf4c405730497266a299e69b632f37887
4
- data.tar.gz: 6146d0c3dc58d7ef9786f95a341a22d500689c42f67ed48c0b824f62a2f250c6
3
+ metadata.gz: 3eb92fffdc685a61fd06c0d5b38b485395eccb0e1762d8efeab0514e74c61b6b
4
+ data.tar.gz: 2237aacb034a1f7f7b1ae41328f0c05ee9393b7d48d6bdca93ca72072e867183
5
5
  SHA512:
6
- metadata.gz: 156abd2537b61dd8416ce75f6b8389e807306869254bef47db51c80e081a433e3b802483327748c0bb18c9c5691dd537fc14f4f536c55505f01e8c7aa2a86849
7
- data.tar.gz: eec30d958642888b92418343ded298ba9ddb49b370395795281e4390494c0a480c771b202b0e7845c1a953c43354cb9aa3404f31589146741fe6d6e2c91f1707
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
@@ -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] || ask('Password: ', echo: false)
87
- puts ''
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
- if e.code == 'otp_token_required'
108
- token_options[:otp_token] = options[:otp_token] ||
109
- ask('2FA Token: ')
110
- retry
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
- raise Thor::Error, 'Could not authenticate with given credentials: ' \
114
- "#{e.code}"
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
@@ -1,5 +1,5 @@
1
1
  module Aptible
2
2
  module CLI
3
- VERSION = '0.16.2'.freeze
3
+ VERSION = '0.16.3'.freeze
4
4
  end
5
5
  end
@@ -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
- before do
34
- m = -> (code) { @code = code }
35
- OAuth2::Error.send :define_method, :initialize, m
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(OAuth2::Error, 'foo')
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 OTP' do
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(OAuth2::Error, 'otp_token_required')
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(OAuth2::Error, 'otp_token_required')
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(OAuth2::Error, 'otp_token_required')
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(OAuth2::Error, 'foo')
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.2
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-07-11 00:00:00.000000000 Z
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