aptible-cli 0.24.10 → 0.26.1

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 (34) 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 +50 -2
  7. data/README.md +2 -1
  8. data/aptible-cli.gemspec +5 -2
  9. data/docker-compose.yml +5 -1
  10. data/lib/aptible/cli/agent.rb +9 -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/helpers/vhost/option_set_builder.rb +8 -15
  15. data/lib/aptible/cli/renderer/text.rb +33 -2
  16. data/lib/aptible/cli/subcommands/aws_accounts.rb +252 -0
  17. data/lib/aptible/cli/subcommands/db.rb +67 -3
  18. data/lib/aptible/cli/subcommands/deploy.rb +45 -11
  19. data/lib/aptible/cli/subcommands/endpoints.rb +0 -2
  20. data/lib/aptible/cli/subcommands/organizations.rb +55 -0
  21. data/lib/aptible/cli/subcommands/services.rb +20 -8
  22. data/lib/aptible/cli/version.rb +1 -1
  23. data/spec/aptible/cli/helpers/database_spec.rb +118 -0
  24. data/spec/aptible/cli/helpers/token_spec.rb +70 -0
  25. data/spec/aptible/cli/subcommands/db_spec.rb +553 -0
  26. data/spec/aptible/cli/subcommands/deploy_spec.rb +42 -7
  27. data/spec/aptible/cli/subcommands/external_aws_accounts_spec.rb +737 -0
  28. data/spec/aptible/cli/subcommands/organizations_spec.rb +90 -0
  29. data/spec/aptible/cli/subcommands/services_spec.rb +77 -0
  30. data/spec/fabricators/app_external_aws_rds_connection_fabricator.rb +55 -0
  31. data/spec/fabricators/external_aws_account_fabricator.rb +49 -0
  32. data/spec/fabricators/external_aws_database_credential_fabricator.rb +46 -0
  33. data/spec/fabricators/external_aws_resource_fabricator.rb +72 -0
  34. metadata +64 -6
@@ -27,6 +27,21 @@ module Aptible
27
27
  accounts = scoped_environments(options)
28
28
  acc_map = environment_map(accounts)
29
29
 
30
+ # the below is done because an rds resource can belong to
31
+ # multiple accounts or none. we go through and iterate
32
+ # through all external_aws_resources and print them based
33
+ # on connections. To avoid repeated API calls, we keep
34
+ # them in these maps and only fetch relations when
35
+ # necessary.
36
+ rds_map = {}
37
+ accts_rds_map = {}
38
+ begin
39
+ rds_map, accts_rds_map = fetch_rds_databases_with_accounts
40
+ rescue StandardError => e
41
+ CLI.logger.warn 'Unable to fetch RDS databases: ' \
42
+ "#{e.message}"
43
+ end
44
+
30
45
  if Renderer.format == 'json'
31
46
  accounts.each do |account|
32
47
  account.each_database do |db|
@@ -34,12 +49,23 @@ module Aptible
34
49
  ResourceFormatter.inject_database(n, db, account)
35
50
  end
36
51
  end
52
+ next unless accts_rds_map.key? account.id
53
+
54
+ accts_rds_map[account.id].each do |rds_db|
55
+ rds_map.delete(rds_db.id)
56
+ node.object do |n|
57
+ ResourceFormatter.inject_database_minimal(
58
+ n,
59
+ rds_db,
60
+ account
61
+ )
62
+ end
63
+ end
37
64
  end
38
65
  else
39
66
  databases_all.each do |db|
40
67
  account = acc_map[db.links.account.href]
41
68
  next if account.nil?
42
-
43
69
  node.object do |n|
44
70
  ResourceFormatter.inject_database_minimal(
45
71
  n,
@@ -48,6 +74,34 @@ module Aptible
48
74
  )
49
75
  end
50
76
  end
77
+ accounts.each do |account|
78
+ next unless accts_rds_map.key? account.id
79
+
80
+ accts_rds_map[account.id].each do |rds_db|
81
+ rds_map.delete(rds_db.id)
82
+ node.object do |n|
83
+ ResourceFormatter.inject_database_minimal(
84
+ n,
85
+ rds_db,
86
+ account
87
+ )
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ # Render unattached RDS databases, but exclude
94
+ # if environment filter set
95
+ unless options[:environment]
96
+ rds_map.each_value do |db|
97
+ node.object do |n|
98
+ ResourceFormatter.inject_database_minimal(
99
+ n,
100
+ db,
101
+ rds_shell_account
102
+ )
103
+ end
104
+ end
51
105
  end
52
106
  end
53
107
  end
@@ -232,9 +286,13 @@ module Aptible
232
286
  define_method 'db:dump' do |handle, *dump_options|
233
287
  telemetry(__method__, options.merge(handle: handle))
234
288
 
289
+ filename = "#{handle}.dump"
290
+ if aws_rds_db?(handle)
291
+ return use_rds_dump(handle, filename, dump_options)
292
+ end
293
+
235
294
  database = ensure_database(options.merge(db: handle))
236
295
  with_postgres_tunnel(database) do |url|
237
- filename = "#{handle}.dump"
238
296
  CLI.logger.info "Dumping to #{filename}"
239
297
  `pg_dump #{url} #{dump_options.shelljoin} > #{filename}`
240
298
  exit $CHILD_STATUS.exitstatus unless $CHILD_STATUS.success?
@@ -252,6 +310,10 @@ module Aptible
252
310
  )
253
311
  telemetry(__method__, opts)
254
312
 
313
+ if aws_rds_db?(handle)
314
+ return use_rds_execute(handle, sql_path, options)
315
+ end
316
+
255
317
  database = ensure_database(options.merge(db: handle))
256
318
  with_postgres_tunnel(database) do |url|
257
319
  CLI.logger.info "Executing #{sql_path} against #{handle}"
@@ -269,8 +331,10 @@ module Aptible
269
331
  telemetry(__method__, options.merge(handle: handle))
270
332
 
271
333
  desired_port = Integer(options[:port] || 0)
272
- database = ensure_database(options.merge(db: handle))
273
334
 
335
+ return use_rds_tunnel(handle, desired_port) if aws_rds_db?(handle)
336
+
337
+ database = ensure_database(options.merge(db: handle))
274
338
  credential = find_credential(database, options[:type])
275
339
 
276
340
  m = "Creating #{credential.type} tunnel to #{database.handle}..."
@@ -2,9 +2,8 @@ module Aptible
2
2
  module CLI
3
3
  module Subcommands
4
4
  module Deploy
5
- DOCKER_IMAGE_DEPLOY_ARGS = Hash[%w(
5
+ DEPRECATED_ENV = Hash[%w(
6
6
  APTIBLE_DOCKER_IMAGE
7
- APTIBLE_PRIVATE_REGISTRY_EMAIL
8
7
  APTIBLE_PRIVATE_REGISTRY_USERNAME
9
8
  APTIBLE_PRIVATE_REGISTRY_PASSWORD
10
9
  ).map do |var|
@@ -40,11 +39,23 @@ module Aptible
40
39
  desc: 'This option only affects new ' \
41
40
  'services, not existing ones. ' \
42
41
  'Examples: m c r'
43
- DOCKER_IMAGE_DEPLOY_ARGS.each_pair do |opt, var|
44
- option opt,
45
- type: :string, banner: var,
46
- desc: "Shorthand for #{var}=..."
47
- end
42
+
43
+ option :docker_image,
44
+ type: :string,
45
+ desc: 'The docker image to deploy. If none specified, ' \
46
+ 'the currently deployed image will be pulled again'
47
+ option :private_registry_username,
48
+ type: :string,
49
+ desc: 'Username for Docker images located in a private ' \
50
+ 'repository'
51
+ option :private_registry_password,
52
+ type: :string,
53
+ desc: 'Password for Docker images located in a private ' \
54
+ 'repository'
55
+ option :private_registry_email,
56
+ type: :string,
57
+ desc: 'This parameter is deprecated'
58
+
48
59
  app_options
49
60
  def deploy(*args)
50
61
  telemetry(__method__, options)
@@ -62,20 +73,43 @@ module Aptible
62
73
 
63
74
  env = extract_env(args)
64
75
 
65
- DOCKER_IMAGE_DEPLOY_ARGS.each_pair do |opt, var|
76
+ DEPRECATED_ENV.each_pair do |opt, var|
66
77
  val = options[opt]
78
+ dasherized = "--#{opt.to_s.tr('_', '-')}"
79
+ if env[var]
80
+ m = "WARNING: The environment variable #{var} " \
81
+ 'will be deprecated. Use the option ' \
82
+ "#{dasherized}, instead."
83
+ CLI.logger.warn m
84
+ end
67
85
  next unless val
68
86
  if env[var] && env[var] != val
69
- dasherized = "--#{opt.to_s.tr('_', '-')}"
70
87
  raise Thor::Error, "The options #{dasherized} and #{var} " \
71
88
  'cannot be set to different values'
72
89
  end
73
- env[var] = val
90
+ end
91
+
92
+ settings = {}
93
+ sensitive_settings = {}
94
+
95
+ if options[:docker_image]
96
+ settings['APTIBLE_DOCKER_IMAGE'] = options[:docker_image]
97
+ end
98
+
99
+ if options[:private_registry_username]
100
+ sensitive_settings['APTIBLE_PRIVATE_REGISTRY_USERNAME'] =
101
+ options[:private_registry_username]
102
+ end
103
+ if options[:private_registry_password]
104
+ sensitive_settings['APTIBLE_PRIVATE_REGISTRY_PASSWORD'] =
105
+ options[:private_registry_password]
74
106
  end
75
107
 
76
108
  opts = {
77
109
  type: 'deploy',
78
110
  env: env,
111
+ settings: settings,
112
+ sensitive_settings: sensitive_settings,
79
113
  git_ref: git_ref,
80
114
  container_count: options[:container_count],
81
115
  container_size: options[:container_size],
@@ -84,7 +118,7 @@ module Aptible
84
118
 
85
119
  allow_it = [
86
120
  opts[:git_ref],
87
- opts[:env].try(:[], 'APTIBLE_DOCKER_IMAGE'),
121
+ opts[:settings].try(:[], 'APTIBLE_DOCKER_IMAGE'),
88
122
  app.status == 'provisioned'
89
123
  ].any? { |x| x }
90
124
 
@@ -160,7 +160,6 @@ module Aptible
160
160
  port!
161
161
  tls!
162
162
  alb!
163
- shared!
164
163
  end
165
164
 
166
165
  desc 'endpoints:https:create [--app APP] SERVICE',
@@ -180,7 +179,6 @@ module Aptible
180
179
  port!
181
180
  tls!
182
181
  alb!
183
- shared!
184
182
  end
185
183
 
186
184
  desc 'endpoints:https:modify [--app APP] ENDPOINT_HOSTNAME',
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aptible
4
+ module CLI
5
+ module Subcommands
6
+ module Organizations
7
+ def self.included(thor)
8
+ thor.class_eval do
9
+ include Helpers::Token
10
+ include Helpers::Telemetry
11
+
12
+ desc 'organizations', 'List all organizations'
13
+ def organizations
14
+ telemetry(__method__, options)
15
+
16
+ user_orgs_and_roles = {}
17
+ begin
18
+ roles = whoami.roles_with_organizations
19
+ rescue HyperResource::ClientError => e
20
+ raise Thor::Error, e.message
21
+ end
22
+ roles.each do |role|
23
+ user_orgs_and_roles[role.organization.id] ||= {
24
+ 'org' => role.organization,
25
+ 'roles' => []
26
+ }
27
+ user_orgs_and_roles[role.organization.id]['roles'] << role
28
+ end
29
+ Formatter.render(Renderer.current) do |root|
30
+ root.list do |list|
31
+ user_orgs_and_roles.each do |org_id, org_and_role|
32
+ org = org_and_role['org']
33
+ roles = org_and_role['roles']
34
+ list.object do |node|
35
+ node.value('id', org_id)
36
+ node.value('name', org.name)
37
+ node.list('roles') do |roles_list|
38
+ roles.each do |role|
39
+ roles_list.object do |role_node|
40
+ role_node.value('id', role.id)
41
+ role_node.value('name', role.name)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -28,16 +28,22 @@ module Aptible
28
28
  desc 'services:settings SERVICE'\
29
29
  ' [--force-zero-downtime|--no-force-zero-downtime]'\
30
30
  ' [--simple-health-check|--no-simple-health-check]'\
31
+ ' [--restart-free-scaling|--no-restart-free-scaling]'\
31
32
  ' [--stop-timeout SECONDS]',
32
33
  'Modifies the deployment settings for a service'
33
34
  app_options
34
35
  option :force_zero_downtime,
35
- type: :boolean, default: false,
36
+ type: :boolean,
36
37
  desc: 'Force zero downtime deployments.'\
37
38
  ' Has no effect if service has an associated Endpoint'
38
39
  option :simple_health_check,
39
- type: :boolean, default: false,
40
+ type: :boolean,
40
41
  desc: 'Use a simple uptime healthcheck during deployments'
42
+ option :restart_free_scaling,
43
+ type: :boolean,
44
+ desc: 'When enabled, scaling operations that only change '\
45
+ 'the number of containers will not restart existing '\
46
+ 'containers'
41
47
  option :stop_timeout,
42
48
  type: :numeric,
43
49
  desc: 'The number of seconds to wait for the service '\
@@ -46,13 +52,19 @@ module Aptible
46
52
  telemetry(__method__, options.merge(service: service))
47
53
 
48
54
  service = ensure_service(options, service)
55
+
49
56
  updates = {}
50
- updates[:force_zero_downtime] =
51
- options[:force_zero_downtime] if options[:force_zero_downtime]
52
- updates[:naive_health_check] =
53
- options[:simple_health_check] if options[:simple_health_check]
54
- updates[:stop_timeout] =
55
- options[:stop_timeout] if options[:stop_timeout]
57
+ vars = [
58
+ :force_zero_downtime, :simple_health_check,
59
+ :restart_free_scaling, :stop_timeout
60
+ ]
61
+ vars.each { |v| updates[v] = options[v] unless options[v].nil? }
62
+ # The var we use with users is different than what the API
63
+ # expects, so we have to map it.
64
+ unless updates[:simple_health_check].nil?
65
+ updates[:naive_health_check] = \
66
+ updates.delete(:simple_health_check)
67
+ end
56
68
 
57
69
  service.update!(**updates) if updates.any?
58
70
  end
@@ -1,5 +1,5 @@
1
1
  module Aptible
2
2
  module CLI
3
- VERSION = '0.24.10'.freeze
3
+ VERSION = '0.26.1'.freeze
4
4
  end
5
5
  end
@@ -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