aptible-cli 0.16.2 → 0.16.7

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.
@@ -4,6 +4,41 @@ module Aptible
4
4
  class << self
5
5
  NO_NESTING = Object.new.freeze
6
6
 
7
+ def inject_backup(node, backup, include_db: false)
8
+ description = "#{backup.id}: #{backup.created_at}, " \
9
+ "#{backup.aws_region}"
10
+
11
+ if include_db
12
+ db = backup.database_with_deleted
13
+ node.keyed_object('database', 'id') do |n|
14
+ inject_deleted_database(n, db, backup.account)
15
+ end
16
+
17
+ description = "#{description}, " \
18
+ "#{db.handle} deleted at #{db.deleted_at}"
19
+ end
20
+
21
+ node.value('id', backup.id)
22
+ node.value('description', description)
23
+ node.value('created_at', backup.created_at)
24
+ node.value('region', backup.aws_region)
25
+ node.value('size', backup.size)
26
+
27
+ if backup.copied_from
28
+ node.keyed_object('copied_from', 'description') do |n|
29
+ inject_backup(n, backup.copied_from)
30
+ end
31
+ end
32
+ end
33
+
34
+ def inject_deleted_database(node, database, account)
35
+ node.value('id', database.id)
36
+ node.value('handle', database.handle)
37
+ node.value('type', database.type)
38
+ node.value('deleted_at', database.deleted_at)
39
+ attach_account(node, account)
40
+ end
41
+
7
42
  def inject_account(node, account)
8
43
  node.value('id', account.id)
9
44
  node.value('handle', account.handle)
@@ -47,6 +82,7 @@ module Aptible
47
82
 
48
83
  node.value('type', database.type)
49
84
  node.value('status', database.status)
85
+
50
86
  node.value('connection_url', database.connection_url)
51
87
 
52
88
  node.list('credentials') do |creds_list|
@@ -54,7 +90,6 @@ module Aptible
54
90
  creds_list.object { |n| inject_credential(n, cred) }
55
91
  end
56
92
  end
57
-
58
93
  attach_account(node, account)
59
94
  end
60
95
 
@@ -9,12 +9,13 @@ module Aptible
9
9
 
10
10
  desc 'backup:restore BACKUP_ID ' \
11
11
  '[--environment ENVIRONMENT_HANDLE] [--handle HANDLE] ' \
12
- '[--container-size SIZE_MB] [--size SIZE_GB]',
12
+ '[--container-size SIZE_MB] [--disk-size SIZE_GB]',
13
13
  'Restore a backup'
14
14
  option :handle, desc: 'a name to use for the new database'
15
15
  option :environment, desc: 'a different environment to restore to'
16
16
  option :container_size, type: :numeric
17
17
  option :size, type: :numeric
18
+ option :disk_size, type: :numeric
18
19
  define_method 'backup:restore' do |backup_id|
19
20
  backup = Aptible::Api::Backup.find(backup_id, token: fetch_token)
20
21
  raise Thor::Error, "Backup ##{backup_id} not found" if backup.nil?
@@ -22,7 +23,8 @@ module Aptible
22
23
  handle = options[:handle]
23
24
  unless handle
24
25
  ts_suffix = backup.created_at.getgm.strftime '%Y-%m-%d-%H-%M-%S'
25
- handle = "#{backup.database.handle}-at-#{ts_suffix}"
26
+ handle =
27
+ "#{backup.database_with_deleted.handle}-at-#{ts_suffix}"
26
28
  end
27
29
 
28
30
  destination_account = if options[:environment]
@@ -35,10 +37,16 @@ module Aptible
35
37
  type: 'restore',
36
38
  handle: handle,
37
39
  container_size: options[:container_size],
38
- disk_size: options[:size],
40
+ disk_size: options[:disk_size] || options[:size],
39
41
  destination_account: destination_account
40
42
  }.delete_if { |_, v| v.nil? }
41
43
 
44
+ CLI.logger.warn([
45
+ 'You have used the "--size" option to specify a disk size.',
46
+ 'This option which be deprecated in a future version.',
47
+ 'Please use the "--disk-size" option, instead.'
48
+ ].join("\n")) if options[:size]
49
+
42
50
  operation = backup.create_operation!(opts)
43
51
  CLI.logger.info "Restoring backup into #{handle}"
44
52
  attach_to_operation_logs(operation)
@@ -62,22 +70,58 @@ module Aptible
62
70
  database = ensure_database(options.merge(db: handle))
63
71
 
64
72
  Formatter.render(Renderer.current) do |root|
65
- root.keyed_list('description') do |l|
73
+ root.keyed_list('description') do |node|
66
74
  database.each_backup do |backup|
67
- break if backup.created_at < min_created_at
68
- description = "#{backup.id}: #{backup.created_at}, " \
69
- "#{backup.aws_region}"
75
+ if backup.created_at < min_created_at && !backup.copied_from
76
+ break
77
+ end
78
+ node.object do |n|
79
+ ResourceFormatter.inject_backup(n, backup)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
70
85
 
71
- l.object do |o|
72
- o.value('id', backup.id)
73
- o.value('description', description)
74
- o.value('created_at', backup.created_at)
75
- o.value('region', backup.aws_region)
86
+ desc 'backup:orphaned', 'List backups associated with ' \
87
+ 'deprovisioned databases'
88
+ option :environment
89
+ option :max_age, default: '1y',
90
+ desc: 'Limit backups returned '\
91
+ '(example usage: 1w, 1y, etc.)'
92
+ define_method 'backup:orphaned' do
93
+ age = ChronicDuration.parse(options[:max_age])
94
+ raise Thor::Error, "Invalid age: #{options[:max_age]}" if age.nil?
95
+ min_created_at = Time.now - age
96
+
97
+ Formatter.render(Renderer.current) do |root|
98
+ root.keyed_list('description') do |node|
99
+ scoped_environments(options).each do |account|
100
+ account.each_orphaned_backup do |backup|
101
+ created_at = backup.created_at
102
+ copied_from = backup.copied_from
103
+ break if created_at < min_created_at && !copied_from
104
+ node.object do |n|
105
+ ResourceFormatter.inject_backup(
106
+ n, backup, include_db: true
107
+ )
108
+ end
76
109
  end
77
110
  end
78
111
  end
79
112
  end
80
113
  end
114
+
115
+ desc 'backup:purge BACKUP_ID',
116
+ 'Permanently delete a backup and any copies of it'
117
+ define_method 'backup:purge' do |backup_id|
118
+ backup = Aptible::Api::Backup.find(backup_id, token: fetch_token)
119
+ raise Thor::Error, "Backup ##{backup_id} not found" if backup.nil?
120
+
121
+ operation = backup.create_operation!(type: 'purge')
122
+ CLI.logger.info "Purging backup #{backup_id}"
123
+ attach_to_operation_logs(operation)
124
+ end
81
125
  end
82
126
  end
83
127
  end
@@ -53,12 +53,13 @@ module Aptible
53
53
 
54
54
  desc 'db:create HANDLE ' \
55
55
  '[--type TYPE] [--version VERSION] ' \
56
- '[--container-size SIZE_MB] [--size SIZE_GB]',
56
+ '[--container-size SIZE_MB] [--disk-size SIZE_GB]',
57
57
  'Create a new database'
58
58
  option :type, type: :string
59
59
  option :version, type: :string
60
60
  option :container_size, type: :numeric
61
- option :size, default: 10, type: :numeric
61
+ option :size, type: :numeric
62
+ option :disk_size, default: 10, type: :numeric
62
63
  option :environment
63
64
  define_method 'db:create' do |handle|
64
65
  account = ensure_environment(options)
@@ -66,9 +67,15 @@ module Aptible
66
67
  db_opts = {
67
68
  handle: handle,
68
69
  initial_container_size: options[:container_size],
69
- initial_disk_size: options[:size]
70
+ initial_disk_size: options[:disk_size] || options[:size]
70
71
  }.delete_if { |_, v| v.nil? }
71
72
 
73
+ CLI.logger.warn([
74
+ 'You have used the "--size" option to specify a disk size.',
75
+ 'This option which be deprecated in a future version.',
76
+ 'Please use the "--disk-size" option, instead.'
77
+ ].join("\n")) if options[:size]
78
+
72
79
  type = options[:type]
73
80
  version = options[:version]
74
81
 
@@ -87,7 +94,7 @@ module Aptible
87
94
  op_opts = {
88
95
  type: 'provision',
89
96
  container_size: options[:container_size],
90
- disk_size: options[:size]
97
+ disk_size: options[:disk_size] || options[:size]
91
98
  }.delete_if { |_, v| v.nil? }
92
99
  op = database.create_operation(op_opts)
93
100
 
@@ -114,6 +121,52 @@ module Aptible
114
121
  render_database(database, database.account)
115
122
  end
116
123
 
124
+ desc 'db:replicate HANDLE REPLICA_HANDLE ' \
125
+ '[--container-size SIZE_MB] [--disk-size SIZE_GB] ' \
126
+ '[--logical --version VERSION]',
127
+ 'Create a replica/follower of a database'
128
+ option :environment
129
+ option :container_size, type: :numeric
130
+ option :size, type: :numeric
131
+ option :disk_size, type: :numeric
132
+ option :logical, type: :boolean
133
+ option :version, type: :string
134
+ define_method 'db:replicate' do |source_handle, dest_handle|
135
+ source = ensure_database(options.merge(db: source_handle))
136
+
137
+ if options[:logical]
138
+ if source.type != 'postgresql'
139
+ raise Thor::Error, 'Logical replication only works for ' \
140
+ 'PostgreSQL'
141
+ end
142
+ if options[:version]
143
+ image = find_database_image(source.type, options[:version])
144
+ else
145
+ raise Thor::Error, '--version is required for logical ' \
146
+ 'replication'
147
+ end
148
+ end
149
+
150
+ CLI.logger.info "Replicating #{source_handle}..."
151
+
152
+ opts = {
153
+ environment: options[:environment],
154
+ container_size: options[:container_size],
155
+ size: options[:disk_size] || options[:size],
156
+ logical: options[:logical],
157
+ database_image: image || nil
158
+ }.delete_if { |_, v| v.nil? }
159
+
160
+ CLI.logger.warn([
161
+ 'You have used the "--size" option to specify a disk size.',
162
+ 'This option which be deprecated in a future version.',
163
+ 'Please use the "--disk-size" option, instead.'
164
+ ].join("\n")) if options[:size]
165
+
166
+ database = replicate_database(source, dest_handle, opts)
167
+ render_database(database.reload, database.account)
168
+ end
169
+
117
170
  desc 'db:dump HANDLE [pg_dump options]',
118
171
  'Dump a remote database to file'
119
172
  option :environment
@@ -221,10 +274,11 @@ module Aptible
221
274
  end
222
275
 
223
276
  desc 'db:restart HANDLE ' \
224
- '[--container-size SIZE_MB] [--size SIZE_GB]',
277
+ '[--container-size SIZE_MB] [--disk-size SIZE_GB]',
225
278
  'Restart a database'
226
279
  option :environment
227
280
  option :container_size, type: :numeric
281
+ option :disk_size, type: :numeric
228
282
  option :size, type: :numeric
229
283
  define_method 'db:restart' do |handle|
230
284
  database = ensure_database(options.merge(db: handle))
@@ -232,9 +286,15 @@ module Aptible
232
286
  opts = {
233
287
  type: 'restart',
234
288
  container_size: options[:container_size],
235
- disk_size: options[:size]
289
+ disk_size: options[:disk_size] || options[:size]
236
290
  }.delete_if { |_, v| v.nil? }
237
291
 
292
+ CLI.logger.warn([
293
+ 'You have used the "--size" option to specify a disk size.',
294
+ 'This option which be deprecated in a future version.',
295
+ 'Please use the "--disk-size" option, instead.'
296
+ ].join("\n")) if options[:size]
297
+
238
298
  CLI.logger.info "Restarting #{database.handle}..."
239
299
  op = database.create_operation!(opts)
240
300
  attach_to_operation_logs(op)
@@ -19,7 +19,7 @@ module Aptible
19
19
  raise "Invalid scheme: #{uri.scheme} (use https)"
20
20
  end
21
21
 
22
- apis = [Aptible::Auth, Aptible::Api, Aptible::Billing]
22
+ apis = [Aptible::Auth, Aptible::Api]
23
23
 
24
24
  api = apis.find do |klass|
25
25
  uri.host == URI(klass.configuration.root_url).host
@@ -1,5 +1,5 @@
1
1
  module Aptible
2
2
  module CLI
3
- VERSION = '0.16.2'.freeze
3
+ VERSION = '0.16.7'.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,134 @@ 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
261
+
262
+ context 'SSO logins' do
263
+ let(:token) { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzUxMiJ9.eyJpZCI6I' }
264
+
265
+ it 'accepts a token as an argument' do
266
+ options = { sso: token }
267
+ allow(subject).to receive(:options).and_return options
268
+
269
+ expect(subject).to receive(:save_token).with(token)
270
+
271
+ subject.login
272
+ end
273
+
274
+ it 'rejects clearly invalid tokens' do
275
+ options = { sso: 'blarg' }
276
+ allow(subject).to receive(:options).and_return options
277
+
278
+ expect { subject.login }.to raise_error Thor::Error
279
+ end
280
+
281
+ it 'prompts for a token if none provided' do
282
+ options = { sso: 'sso' }
283
+ allow(subject).to receive(:options).and_return options
284
+
285
+ expect(subject).to receive(:ask).once.and_return(token)
286
+ expect(subject).to receive(:save_token).with(token)
287
+
288
+ subject.login
289
+ end
290
+ end
169
291
  end
170
292
  end
171
293