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 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