aptible-cli 0.25.0 → 0.26.2

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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +27 -0
  3. data/.github/workflows/test.yml +1 -1
  4. data/Dockerfile +8 -5
  5. data/Gemfile.lock +28 -17
  6. data/Makefile +53 -2
  7. data/README.md +83 -82
  8. data/aptible-cli.gemspec +5 -2
  9. data/docker-compose.yml +5 -1
  10. data/lib/aptible/cli/agent.rb +16 -0
  11. data/lib/aptible/cli/helpers/aws_account.rb +158 -0
  12. data/lib/aptible/cli/helpers/database.rb +182 -2
  13. data/lib/aptible/cli/helpers/token.rb +14 -0
  14. data/lib/aptible/cli/renderer/text.rb +33 -2
  15. data/lib/aptible/cli/resource_formatter.rb +2 -2
  16. data/lib/aptible/cli/subcommands/apps.rb +2 -2
  17. data/lib/aptible/cli/subcommands/aws_accounts.rb +252 -0
  18. data/lib/aptible/cli/subcommands/config.rb +6 -6
  19. data/lib/aptible/cli/subcommands/db.rb +67 -3
  20. data/lib/aptible/cli/subcommands/deploy.rb +46 -12
  21. data/lib/aptible/cli/subcommands/organizations.rb +55 -0
  22. data/lib/aptible/cli/subcommands/rebuild.rb +2 -1
  23. data/lib/aptible/cli/subcommands/restart.rb +2 -1
  24. data/lib/aptible/cli/subcommands/services.rb +24 -12
  25. data/lib/aptible/cli/subcommands/ssh.rb +1 -1
  26. data/lib/aptible/cli/version.rb +1 -1
  27. data/spec/aptible/cli/agent_spec.rb +70 -0
  28. data/spec/aptible/cli/helpers/database_spec.rb +118 -0
  29. data/spec/aptible/cli/helpers/token_spec.rb +70 -0
  30. data/spec/aptible/cli/subcommands/db_spec.rb +553 -0
  31. data/spec/aptible/cli/subcommands/deploy_spec.rb +42 -7
  32. data/spec/aptible/cli/subcommands/external_aws_accounts_spec.rb +737 -0
  33. data/spec/aptible/cli/subcommands/organizations_spec.rb +90 -0
  34. data/spec/aptible/cli/subcommands/services_spec.rb +77 -0
  35. data/spec/fabricators/app_external_aws_rds_connection_fabricator.rb +55 -0
  36. data/spec/fabricators/external_aws_account_fabricator.rb +49 -0
  37. data/spec/fabricators/external_aws_database_credential_fabricator.rb +46 -0
  38. data/spec/fabricators/external_aws_resource_fabricator.rb +72 -0
  39. metadata +65 -7
@@ -10,7 +10,7 @@ module Aptible
10
10
  include Helpers::App
11
11
  include Helpers::Telemetry
12
12
 
13
- desc 'ssh [COMMAND]', 'Run a command against an app'
13
+ desc 'ssh [--app APP] [COMMAND]', 'Run a command against an app'
14
14
  long_desc <<-LONGDESC
15
15
  Runs an interactive command against a remote Aptible app
16
16
 
@@ -1,5 +1,5 @@
1
1
  module Aptible
2
2
  module CLI
3
- VERSION = '0.25.0'.freeze
3
+ VERSION = '0.26.2'.freeze
4
4
  end
5
5
  end
@@ -428,6 +428,76 @@ describe Aptible::CLI::Agent do
428
428
  end
429
429
  end
430
430
 
431
+ context 'failed_login_attempt_id handling' do
432
+ before do
433
+ allow(subject).to receive(:options)
434
+ .and_return(email: email, password: password)
435
+ expect(subject).to receive(:ask).with('2FA Token: ')
436
+ .once
437
+ .and_return(token)
438
+ end
439
+
440
+ it 'should extract failed_login_attempt_id when present' do
441
+ e = make_oauth2_error(
442
+ 'otp_token_required',
443
+ 'failed_login_attempt_id' => 'attempt-123'
444
+ )
445
+
446
+ expect(Aptible::Auth::Token).to receive(:create)
447
+ .with(email: email, password: password, expires_in: 1.week.seconds)
448
+ .once
449
+ .and_raise(e)
450
+
451
+ expect(Aptible::Auth::Token).to receive(:create)
452
+ .with(email: email, password: password, otp_token: token,
453
+ previous_login_attempt_id: 'attempt-123',
454
+ expires_in: 12.hours.seconds)
455
+ .once
456
+ .and_return(token)
457
+
458
+ subject.login
459
+ end
460
+
461
+ it 'should handle nil exception_context gracefully' do
462
+ parsed = { 'error' => 'otp_token_required' }
463
+ response = double('response',
464
+ parsed: parsed,
465
+ body: 'error otp_token_required')
466
+ allow(response).to receive(:error=)
467
+ e = OAuth2::Error.new(response)
468
+
469
+ expect(Aptible::Auth::Token).to receive(:create)
470
+ .with(email: email, password: password, expires_in: 1.week.seconds)
471
+ .once
472
+ .and_raise(e)
473
+
474
+ expect(Aptible::Auth::Token).to receive(:create)
475
+ .with(email: email, password: password, otp_token: token,
476
+ expires_in: 12.hours.seconds)
477
+ .once
478
+ .and_return(token)
479
+
480
+ subject.login
481
+ end
482
+
483
+ it 'should handle missing failed_login_attempt_id gracefully' do
484
+ e = make_oauth2_error('otp_token_required', {})
485
+
486
+ expect(Aptible::Auth::Token).to receive(:create)
487
+ .with(email: email, password: password, expires_in: 1.week.seconds)
488
+ .once
489
+ .and_raise(e)
490
+
491
+ expect(Aptible::Auth::Token).to receive(:create)
492
+ .with(email: email, password: password, otp_token: token,
493
+ expires_in: 12.hours.seconds)
494
+ .once
495
+ .and_return(token)
496
+
497
+ subject.login
498
+ end
499
+ end
500
+
431
501
  context 'SSO logins' do
432
502
  let(:token) { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzUxMiJ9.eyJpZCI6I' }
433
503
 
@@ -33,4 +33,122 @@ describe Aptible::CLI::Helpers::Database do
33
33
  expect(subject.validate_image_type(pg.type)).to be(true)
34
34
  end
35
35
  end
36
+
37
+ describe '#derive_account_from_conns' do
38
+ let(:stack) { Fabricate(:stack) }
39
+ let(:account1) { Fabricate(:account, handle: 'account1', stack: stack) }
40
+ let(:account2) { Fabricate(:account, handle: 'account2', stack: stack) }
41
+ let(:app1) { Fabricate(:app, account: account1) }
42
+ let(:app2) { Fabricate(:app, account: account2) }
43
+
44
+ let(:raw_rds_resource) do
45
+ Fabricate(:external_aws_resource, resource_type: 'aws_rds_db_instance')
46
+ end
47
+
48
+ let(:rds_db) do
49
+ Aptible::CLI::Helpers::Database::RdsDatabase.new(
50
+ 'aws:rds::test-db',
51
+ raw_rds_resource.id,
52
+ raw_rds_resource.created_at,
53
+ raw_rds_resource
54
+ )
55
+ end
56
+
57
+ let(:conn1) do
58
+ double('connection1', present?: true, app: app1)
59
+ end
60
+
61
+ let(:conn2) do
62
+ double('connection2', present?: true, app: app2)
63
+ end
64
+
65
+ before do
66
+ allow(app1).to receive(:account).and_return(account1)
67
+ allow(app2).to receive(:account).and_return(account2)
68
+ end
69
+
70
+ context 'when connections are empty' do
71
+ it 'returns nil' do
72
+ raw_rds_resource.instance_variable_set(
73
+ :@app_external_aws_rds_connections,
74
+ []
75
+ )
76
+
77
+ result = subject.derive_account_from_conns(rds_db)
78
+ expect(result).to be_nil
79
+ end
80
+ end
81
+
82
+ context 'when no preferred account is specified' do
83
+ it 'returns the account from the first connection' do
84
+ raw_rds_resource.instance_variable_set(
85
+ :@app_external_aws_rds_connections,
86
+ [conn1, conn2]
87
+ )
88
+
89
+ result = subject.derive_account_from_conns(rds_db)
90
+ expect(result).to eq(account1)
91
+ end
92
+
93
+ it 'handles a single connection' do
94
+ raw_rds_resource.instance_variable_set(
95
+ :@app_external_aws_rds_connections,
96
+ [conn2]
97
+ )
98
+
99
+ result = subject.derive_account_from_conns(rds_db)
100
+ expect(result).to eq(account2)
101
+ end
102
+ end
103
+
104
+ context 'when a preferred account is specified' do
105
+ it 'returns the matching account when found' do
106
+ raw_rds_resource.instance_variable_set(
107
+ :@app_external_aws_rds_connections,
108
+ [conn1, conn2]
109
+ )
110
+
111
+ result = subject.derive_account_from_conns(rds_db, account2)
112
+ expect(result).to eq(account2)
113
+ end
114
+
115
+ it 'returns nil when no matching connection is found' do
116
+ account3 = Fabricate(:account, handle: 'account3', stack: stack)
117
+ raw_rds_resource.instance_variable_set(
118
+ :@app_external_aws_rds_connections,
119
+ [conn1, conn2]
120
+ )
121
+
122
+ result = subject.derive_account_from_conns(rds_db, account3)
123
+ expect(result).to be_nil
124
+ end
125
+
126
+ it 'skips connections where conn.present? is false' do
127
+ conn_not_present = double('connection_not_present', present?: false)
128
+ raw_rds_resource.instance_variable_set(
129
+ :@app_external_aws_rds_connections,
130
+ [conn_not_present, conn2]
131
+ )
132
+
133
+ result = subject.derive_account_from_conns(rds_db, account2)
134
+ expect(result).to eq(account2)
135
+ end
136
+
137
+ it 'returns the first matching account when multiple matches exist' do
138
+ app1_duplicate = Fabricate(:app, account: account1)
139
+ allow(app1_duplicate).to receive(:account).and_return(account1)
140
+ conn1_duplicate = double('connection1_dup',
141
+ present?: true,
142
+ app: app1_duplicate)
143
+
144
+ raw_rds_resource.instance_variable_set(
145
+ :@app_external_aws_rds_connections,
146
+ [conn1, conn1_duplicate]
147
+ )
148
+
149
+ result = subject.derive_account_from_conns(rds_db, account1)
150
+ expect(result).to eq(account1)
151
+ end
152
+ end
153
+ end
36
154
  end
@@ -7,6 +7,10 @@ describe Aptible::CLI::Helpers::Token do
7
7
 
8
8
  subject { Class.new.send(:include, described_class).new }
9
9
 
10
+ let(:token) { 'test-token' }
11
+ let(:user) { double('user', id: 'user-id', email: 'test@example.com') }
12
+ let(:auth_token) { double('auth_token', user: user) }
13
+
10
14
  describe '#save_token / #fetch_token' do
11
15
  it 'reads back a token it saved' do
12
16
  subject.save_token('foo')
@@ -38,4 +42,70 @@ describe Aptible::CLI::Helpers::Token do
38
42
  end
39
43
  end
40
44
  end
45
+
46
+ describe '#current_token' do
47
+ before do
48
+ subject.save_token(token)
49
+ end
50
+
51
+ it 'returns the current auth token' do
52
+ expect(Aptible::Auth::Token).to receive(:current_token)
53
+ .with(token: token)
54
+ .and_return(auth_token)
55
+
56
+ expect(subject.current_token).to eq(auth_token)
57
+ end
58
+
59
+ it 'raises Thor::Error on 401 unauthorized' do
60
+ response = Faraday::Response.new(status: 401)
61
+ error = HyperResource::ClientError.new(
62
+ '401 (invalid_token) Invalid Token', response: response
63
+ )
64
+ expect(Aptible::Auth::Token).to receive(:current_token)
65
+ .with(token: token)
66
+ .and_raise(error)
67
+
68
+ expect { subject.current_token }
69
+ .to raise_error(Thor::Error, /Invalid Token/)
70
+ end
71
+
72
+ it 'raises Thor::Error on 403 forbidden' do
73
+ response = Faraday::Response.new(status: 403)
74
+ error = HyperResource::ClientError.new('403 (forbidden) Access denied',
75
+ response: response)
76
+ expect(Aptible::Auth::Token).to receive(:current_token)
77
+ .with(token: token)
78
+ .and_raise(error)
79
+
80
+ expect { subject.current_token }
81
+ .to raise_error(Thor::Error, /Access denied/)
82
+ end
83
+ end
84
+
85
+ describe '#whoami' do
86
+ before do
87
+ subject.save_token(token)
88
+ end
89
+
90
+ it 'returns the current user' do
91
+ expect(Aptible::Auth::Token).to receive(:current_token)
92
+ .with(token: token)
93
+ .and_return(auth_token)
94
+
95
+ expect(subject.whoami).to eq(user)
96
+ end
97
+
98
+ it 'raises Thor::Error on API error' do
99
+ response = Faraday::Response.new(status: 401)
100
+ error = HyperResource::ClientError.new(
101
+ '401 (invalid_token) Invalid Token', response: response
102
+ )
103
+ expect(Aptible::Auth::Token).to receive(:current_token)
104
+ .with(token: token)
105
+ .and_raise(error)
106
+
107
+ expect { subject.whoami }
108
+ .to raise_error(Thor::Error, /Invalid Token/)
109
+ end
110
+ end
41
111
  end