aptible-cli 0.16.2 → 0.16.7

Sign up to get free protection for your applications and to get access to all the features.
@@ -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