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
@@ -8,6 +8,23 @@ module Aptible
8
8
  include Helpers::Environment
9
9
  include Helpers::Ssh
10
10
 
11
+ # RdsDatabase is a translation struct so the same renderer can be
12
+ # used for external_aws_resource as those for databases
13
+ RdsDatabase = Struct.new(:handle, :id, :created_at, :raw)
14
+ # MockRdsDatabaseAccountShell - there is no direct 1:1 mapping
15
+ # between accounts and external_aws_resources. Since this is
16
+ # coerced via app_external_aws_rds_connections, we use this
17
+ # struct to stub out those that are not found to be attached to
18
+ # any apps.
19
+ MockRdsDatabaseAccountShell = Struct.new(
20
+ :handle,
21
+ :id,
22
+ :created_at
23
+ )
24
+ # using an ID that cannot be hit for visual segregation of
25
+ # unattached databases
26
+ UNATTACHED_RDS_ACCOUNT_ID = -9999
27
+
11
28
  def ensure_database(options = {})
12
29
  db_handle = options[:db]
13
30
  environment_handle = options[:environment]
@@ -46,6 +63,83 @@ module Aptible
46
63
  )
47
64
  end
48
65
 
66
+ def aws_rds_db?(handle)
67
+ handle.start_with? 'aws:rds::'
68
+ end
69
+
70
+ def external_rds_databases_map
71
+ external_rds_databases_all.map { |rds| [rds[:id], rds] }.to_h
72
+ end
73
+
74
+ def fetch_rds_databases_with_accounts
75
+ rds_map = external_rds_databases_map
76
+ accts_rds_map = accounts_external_rds_databases_map(rds_map)
77
+ [rds_map, accts_rds_map]
78
+ end
79
+
80
+ def accounts_external_rds_databases_map(rds_map)
81
+ return {} if rds_map.empty?
82
+
83
+ map_of_accounts_to_rds(rds_map)
84
+ end
85
+
86
+ def map_of_accounts_to_rds(rds_map)
87
+ # one rds db can be on multiple accounts
88
+ accts_rds_map = {}
89
+ rds_map.each_value do |db|
90
+ account = derive_account_from_conns(db)
91
+ next if account.nil?
92
+
93
+ accts_rds_map[account.id] = [] if accts_rds_map[account.id].nil?
94
+ accts_rds_map[account.id] << db
95
+ end
96
+ accts_rds_map
97
+ end
98
+
99
+ def rds_shell_account
100
+ MockRdsDatabaseAccountShell.new(
101
+ 'unattached rds databases',
102
+ UNATTACHED_RDS_ACCOUNT_ID
103
+ )
104
+ end
105
+
106
+ def external_rds_databases_all
107
+ Aptible::Api::ExternalAwsResource
108
+ .all(
109
+ token: fetch_token
110
+ )
111
+ .select { |db| db.resource_type == 'aws_rds_db_instance' }
112
+ .map do |db|
113
+ RdsDatabase.new(
114
+ "aws:rds::#{db.resource_name}",
115
+ db.id,
116
+ db.created_at,
117
+ db
118
+ )
119
+ end
120
+ end
121
+
122
+ def derive_account_from_conns(db, preferred_acct = nil)
123
+ conns = db.raw.app_external_aws_rds_connections
124
+ return nil if conns.empty?
125
+
126
+ if preferred_acct.present?
127
+ valid_conns = conns.find do |conn|
128
+ conn.present? && conn.app.account.id == preferred_acct.id
129
+ end
130
+ return nil if valid_conns.nil?
131
+ return valid_conns.app.account
132
+ end
133
+
134
+ first_present_conn = conns.find(&:present?)
135
+ return nil if first_present_conn.nil?
136
+ first_present_conn.app.account
137
+ end
138
+
139
+ def external_rds_database_from_handle(handle)
140
+ external_rds_databases_all.find { |a| a.handle == handle }
141
+ end
142
+
49
143
  def databases_from_handle(handle, environment)
50
144
  databases = if environment
51
145
  environment.databases
@@ -90,8 +184,19 @@ module Aptible
90
184
 
91
185
  # Creates a local tunnel and yields the helper
92
186
 
93
- def with_local_tunnel(credential, port = 0)
94
- op = credential.create_operation!(type: 'tunnel', status: 'succeeded')
187
+ def with_local_tunnel(credential, port = 0, target_account = nil)
188
+ op = if target_account.nil?
189
+ credential.create_operation!(
190
+ type: 'tunnel',
191
+ status: 'succeeded'
192
+ )
193
+ else
194
+ credential.create_operation!(
195
+ type: 'tunnel',
196
+ status: 'succeeded',
197
+ destination_account: target_account.id
198
+ )
199
+ end
95
200
 
96
201
  with_ssh_cmd(op) do |base_ssh_cmd, ssh_credential|
97
202
  ssh_cmd = base_ssh_cmd + ['-o', 'SendEnv=ACCESS_TOKEN']
@@ -106,6 +211,72 @@ module Aptible
106
211
  end
107
212
  end
108
213
 
214
+ def with_rds_tunnel(handle, port = 0)
215
+ external_rds = external_rds_database_from_handle(handle)
216
+ if external_rds.nil?
217
+ raise Thor::Error, "No rds db found with handle #{handle}"
218
+ end
219
+
220
+ credential = external_rds.raw.external_aws_database_credentials.first
221
+ if credential.nil?
222
+ raise Thor::Error, 'No rds credential found with handle ' \
223
+ "#{handle}. Check to see if you have run " \
224
+ 'db:attach or a scan has properly completed.'
225
+ end
226
+
227
+ target_account = derive_account_from_conns(external_rds)
228
+ if target_account.nil?
229
+ raise Thor::Error,
230
+ "No env for rds found with handle #{handle}. Check to see " \
231
+ 'if you have run db:attach or a scan has properly completed.'
232
+ end
233
+
234
+ with_local_tunnel(credential, port, target_account) do |tunnel_helper|
235
+ url = local_rds_url(credential, tunnel_helper.port, target_account)
236
+ yield url, tunnel_helper
237
+ end
238
+ end
239
+
240
+ def use_rds_tunnel(handle, port)
241
+ with_rds_tunnel(handle, port) do |url, tunnel_helper|
242
+ CLI.logger.info "Connect at #{url}"
243
+
244
+ uri = URI(url)
245
+ db = uri.path.gsub(%r{^/}, '')
246
+ CLI.logger.info 'Or, use the following arguments:'
247
+ CLI.logger.info "* Host: #{uri.host}"
248
+ CLI.logger.info "* Port: #{uri.port}"
249
+ CLI.logger.info "* Username: #{uri.user}" unless uri.user.empty?
250
+ CLI.logger.info "* Password: #{uri.password}"
251
+ CLI.logger.info "* Database: #{db}" unless db.empty?
252
+
253
+ CLI.logger.info 'Connected. Ctrl-C to close connection.'
254
+
255
+ begin
256
+ tunnel_helper.wait
257
+ rescue Interrupt
258
+ CLI.logger.warn 'Closing tunnel'
259
+ end
260
+ end
261
+ end
262
+
263
+ def use_rds_dump(handle, filename, dump_options)
264
+ with_rds_tunnel(handle) do |url|
265
+ CLI.logger.info "Dumping to #{filename}"
266
+ `pg_dump #{url} #{dump_options.shelljoin} > #{filename}`
267
+ exit $CHILD_STATUS.exitstatus unless $CHILD_STATUS.success?
268
+ end
269
+ end
270
+
271
+ def use_rds_execute(handle, sql_path, options)
272
+ with_rds_tunnel(handle) do |url|
273
+ CLI.logger.info "Executing #{sql_path} against #{handle}"
274
+ args = options[:on_error_stop] ? '-v ON_ERROR_STOP=true ' : ''
275
+ `psql #{args}#{url} < #{sql_path}`
276
+ exit $CHILD_STATUS.exitstatus unless $CHILD_STATUS.success?
277
+ end
278
+ end
279
+
109
280
  # Creates a local PG tunnel and yields the url to it
110
281
 
111
282
  def with_postgres_tunnel(database)
@@ -120,6 +291,15 @@ module Aptible
120
291
  end
121
292
  end
122
293
 
294
+ def local_rds_url(credential, local_port, forced_account)
295
+ remote_url = credential.connection_url
296
+
297
+ uri = URI.parse(remote_url)
298
+ domain = forced_account.stack.internal_domain
299
+ "#{uri.scheme}://#{uri.user}:#{uri.password}@" \
300
+ "localhost.#{domain}:#{local_port}#{uri.path}"
301
+ end
302
+
123
303
  def local_url(credential, local_port)
124
304
  remote_url = credential.connection_url
125
305
 
@@ -52,6 +52,20 @@ module Aptible
52
52
  tok = fetch_token
53
53
  JWT.decode(tok, nil, false)
54
54
  end
55
+
56
+ # Instance of Aptible::Auth::Token from current token
57
+ def current_token
58
+ Aptible::Auth::Token.current_token(token: fetch_token)
59
+ rescue HyperResource::ClientError => e
60
+ raise Thor::Error, e.message
61
+ end
62
+
63
+ # Instance of Aptible::Auth::User associated with current token
64
+ def whoami
65
+ current_token.user
66
+ rescue HyperResource::ClientError => e
67
+ raise Thor::Error, e.message
68
+ end
55
69
  end
56
70
  end
57
71
  end
@@ -12,7 +12,6 @@ module Aptible
12
12
  ports
13
13
  port
14
14
  alb
15
- shared
16
15
  ).freeze
17
16
 
18
17
  def initialize(&block)
@@ -63,6 +62,13 @@ module Aptible
63
62
  'least_outstanding_requests, and ' \
64
63
  'weighted_random'
65
64
  )
65
+
66
+ option(
67
+ :shared,
68
+ type: :boolean,
69
+ desc: "Share this Endpoint's load balancer with other " \
70
+ 'Endpoints'
71
+ )
66
72
  end
67
73
  end
68
74
 
@@ -123,15 +129,6 @@ module Aptible
123
129
  'on this Endpoint'
124
130
  )
125
131
  end
126
-
127
- if builder.shared?
128
- option(
129
- :shared,
130
- type: :boolean,
131
- desc: "Share this Endpoint's load balancer with other " \
132
- 'Endpoints'
133
- )
134
- end
135
132
  end
136
133
  end
137
134
 
@@ -203,11 +200,7 @@ module Aptible
203
200
  params[:load_balancing_algorithm_type] = lba_type
204
201
  end
205
202
 
206
- if shared?
207
- params[:shared] = options.delete(:shared) do
208
- create? ? false : nil
209
- end
210
- end
203
+ params[:shared] = options.delete(:shared)
211
204
  end
212
205
 
213
206
  options.delete(:environment)
@@ -32,8 +32,14 @@ module Aptible
32
32
  # children are KeyedObject instances so they can render properly,
33
33
  # but we need to warn in tests that this is required.
34
34
  node.children.each_pair do |k, c|
35
- io.print "#{format_key(k)}: "
36
- visit(c, io)
35
+ io.print "#{format_key(k)}:"
36
+ if c.is_a?(Formatter::List)
37
+ io.puts
38
+ visit_indented(c, io, ' ')
39
+ else
40
+ io.print ' '
41
+ visit(c, io)
42
+ end
37
43
  end
38
44
  when Formatter::GroupedKeyedList
39
45
  enum = spacer_enumerator
@@ -65,6 +71,31 @@ module Aptible
65
71
 
66
72
  private
67
73
 
74
+ def visit_indented(node, io, indent)
75
+ return unless node.is_a?(Formatter::List)
76
+
77
+ node.children.each do |child|
78
+ case child
79
+ when Formatter::Object
80
+ child.children.each_pair do |k, c|
81
+ io.print "#{indent}#{format_key(k)}:"
82
+ if c.is_a?(Formatter::List)
83
+ io.puts
84
+ visit_indented(c, io, indent + ' ')
85
+ else
86
+ io.print ' '
87
+ visit(c, io)
88
+ end
89
+ end
90
+ io.puts unless child == node.children.last
91
+ when Formatter::Value
92
+ io.puts "#{indent}#{child.value}"
93
+ else
94
+ visit(child, io)
95
+ end
96
+ end
97
+ end
98
+
68
99
  def output_list(nodes, io)
69
100
  if nodes.all? { |v| v.is_a?(Formatter::Value) }
70
101
  # All nodes are single values, so we render one per line.
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aptible
4
+ module CLI
5
+ module Subcommands
6
+ module AwsAccounts
7
+ def self.included(thor)
8
+ thor.class_eval do
9
+ include Helpers::Token
10
+ include Helpers::AwsAccount
11
+ include Helpers::Telemetry
12
+
13
+ desc 'aws_accounts', 'List external AWS accounts', hide: true
14
+ option :organization_id, aliases: '--org-id',
15
+ type: :string,
16
+ default: nil,
17
+ desc: 'Organization ID'
18
+ def aws_accounts
19
+ telemetry(__method__, options)
20
+
21
+ accounts = aws_accounts_all
22
+
23
+ Formatter.render(Renderer.current) do |root|
24
+ root.list do |list|
25
+ accounts.each do |ext|
26
+ list.object do |node|
27
+ node.value('id', ext.id) if ext.respond_to?(:id)
28
+ attrs = ext.respond_to?(:attributes) ? ext.attributes : {}
29
+ %w(
30
+ aws_account_id
31
+ account_name
32
+ aws_region_primary
33
+ status
34
+ discovery_enabled
35
+ discovery_role_arn
36
+ discovery_frequency
37
+ account_id
38
+ created_at
39
+ updated_at
40
+ ).each do |k|
41
+ v = attrs[k]
42
+ node.value(k, v) unless v.nil?
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ desc 'aws_accounts:add ' \
51
+ '[--account-name ACCOUNT_NAME] ' \
52
+ '[--aws-account-id AWS_ACCOUNT_ID] ' \
53
+ '[--org-id ORGANIZATION_ID] '\
54
+ '[--aws-region-primary AWS_REGION] ' \
55
+ '[--discovery-enabled|--no-discovery-enabled] ' \
56
+ '[--discovery-role-arn DISCOVERY_ROLE_ARN] ' \
57
+ '[--discovery-frequency FREQ]', \
58
+ 'Add a new external AWS account', hide: true
59
+ option :account_name, type: :string, desc: 'Display name'
60
+ option :aws_account_id, type: :string, desc: 'AWS Account ID'
61
+ option :organization_id, aliases: '--org-id',
62
+ type: :string,
63
+ default: nil,
64
+ desc: 'Organization ID'
65
+ option :aws_region_primary, type: :string,
66
+ desc: 'Primary AWS region'
67
+ option :discovery_enabled, type: :boolean,
68
+ desc: 'Enable resource discovery'
69
+ option :discovery_role_arn, type: :string,
70
+ desc: 'IAM Role ARN that Aptible ' \
71
+ 'will assume to discover ' \
72
+ 'resources in your AWS account'
73
+ option :discovery_frequency,
74
+ type: :string,
75
+ desc: 'Discovery frequency (e.g., daily)'
76
+ define_method 'aws_accounts:add' do
77
+ telemetry(__method__, options)
78
+
79
+ resource = create_external_aws_account!(options)
80
+
81
+ Formatter.render(Renderer.current) do |root|
82
+ root.object do |node|
83
+ node.value('id', resource.id) if resource.respond_to?(:id)
84
+ rattrs =
85
+ if resource.respond_to?(:attributes)
86
+ resource.attributes
87
+ else
88
+ {}
89
+ end
90
+ %w(
91
+ aws_account_id
92
+ account_name
93
+ aws_region_primary
94
+ discovery_enabled
95
+ discovery_role_arn
96
+ discovery_frequency
97
+ account_id
98
+ created_at
99
+ updated_at
100
+ ).each do |k|
101
+ v = rattrs[k]
102
+ node.value(k, v) unless v.nil?
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ desc 'aws_accounts:show ID',
109
+ 'Show an external AWS account', \
110
+ hide: true
111
+ define_method 'aws_accounts:show' do |id|
112
+ telemetry(__method__, options.merge(id: id))
113
+ ext = ensure_external_aws_account(id)
114
+ Formatter.render(Renderer.current) do |root|
115
+ root.object do |node|
116
+ node.value('id', ext.id)
117
+ rattrs =
118
+ if ext.respond_to?(:attributes)
119
+ ext.attributes
120
+ else
121
+ {}
122
+ end
123
+ %w(
124
+ aws_account_id
125
+ account_name
126
+ aws_region_primary
127
+ discovery_enabled
128
+ discovery_role_arn
129
+ discovery_frequency
130
+ account_id
131
+ created_at
132
+ updated_at
133
+ ).each do |k|
134
+ v = rattrs[k]
135
+ node.value(k, v) unless v.nil?
136
+ end
137
+ end
138
+ end
139
+ end
140
+
141
+ desc 'aws_accounts:delete ID',
142
+ 'Delete an external AWS account', \
143
+ hide: true
144
+ define_method 'aws_accounts:delete' do |id|
145
+ telemetry(__method__, options.merge(id: id))
146
+
147
+ delete_external_aws_account!(id)
148
+
149
+ Formatter.render(Renderer.current) do |root|
150
+ root.object do |node|
151
+ node.value('id', id)
152
+ node.value('deleted', true)
153
+ end
154
+ end
155
+ end
156
+
157
+ desc 'aws_accounts:update ID ' \
158
+ '[--account-name ACCOUNT_NAME] ' \
159
+ '[--aws-account-id AWS_ACCOUNT_ID] ' \
160
+ '[--aws-region-primary AWS_REGION] ' \
161
+ '[--discovery-enabled|--no-discovery-enabled] ' \
162
+ '[--discovery-role-arn DISCOVERY_ROLE_ARN] ' \
163
+ '[--remove-discovery-role-arn] ' \
164
+ '[--discovery-frequency FREQ]', \
165
+ 'Update an external AWS account', hide: true
166
+ option :account_name, type: :string, desc: 'New display name'
167
+ option :aws_account_id, type: :string, desc: 'AWS Account ID'
168
+ option :aws_region_primary, type: :string,
169
+ desc: 'Primary AWS region'
170
+ option :discovery_enabled, type: :boolean,
171
+ desc: 'Enable resource discovery'
172
+ option :discovery_role_arn, type: :string,
173
+ desc: 'IAM Role ARN that Aptible ' \
174
+ 'will assume to discover ' \
175
+ 'resources in your AWS account'
176
+ option :remove_discovery_role_arn, type: :boolean,
177
+ desc: 'Remove the discovery ' \
178
+ 'role ARN from this ' \
179
+ 'account'
180
+ option :discovery_frequency,
181
+ type: :string,
182
+ desc: 'Discovery frequency (e.g., daily)'
183
+ define_method 'aws_accounts:update' do |id|
184
+ telemetry(__method__, options.merge(id: id))
185
+
186
+ ext = update_external_aws_account!(id, options)
187
+
188
+ Formatter.render(Renderer.current) do |root|
189
+ root.object do |node|
190
+ node.value('id', ext.id)
191
+ rattrs =
192
+ if ext.respond_to?(:attributes)
193
+ ext.attributes
194
+ else
195
+ {}
196
+ end
197
+ %w(
198
+ aws_account_id
199
+ account_name
200
+ aws_region_primary
201
+ discovery_enabled
202
+ discovery_role_arn
203
+ discovery_frequency
204
+ account_id
205
+ created_at
206
+ updated_at
207
+ ).each do |k|
208
+ v = rattrs[k]
209
+ node.value(k, v) unless v.nil?
210
+ end
211
+ end
212
+ end
213
+ end
214
+
215
+ desc 'aws_accounts:check ID',
216
+ 'Check the connection for an external AWS account', \
217
+ hide: true
218
+ define_method 'aws_accounts:check' do |id|
219
+ telemetry(__method__, options.merge(id: id))
220
+
221
+ response = check_external_aws_account!(id)
222
+
223
+ fmt_state = lambda do |state|
224
+ Renderer.format == 'json' ? state : format_check_state(state)
225
+ end
226
+
227
+ Formatter.render(Renderer.current) do |root|
228
+ root.object do |node|
229
+ node.value('state', fmt_state.call(response.state))
230
+ node.list('checks') do |check_list|
231
+ response.checks.each do |check|
232
+ check_list.object do |check_node|
233
+ check_node.value('name', check.check_name)
234
+ check_node.value('state', fmt_state.call(check.state))
235
+ check_node.value('details', check.details) \
236
+ unless check.details.nil?
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
242
+
243
+ unless response.state == 'success'
244
+ raise Thor::Error, 'AWS account check failed'
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end