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
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aptible/api'
4
+
5
+ module Aptible
6
+ module CLI
7
+ module Helpers
8
+ module AwsAccount
9
+ include Helpers::Token
10
+
11
+ def aws_accounts_href
12
+ if Renderer.format == 'json'
13
+ '/external_aws_accounts'
14
+ else
15
+ '/external_aws_accounts?per_page=5000&no_embed=true'
16
+ end
17
+ end
18
+
19
+ def aws_accounts_all
20
+ Aptible::Api::ExternalAwsAccount.all(
21
+ token: fetch_token,
22
+ href: aws_accounts_href
23
+ )
24
+ end
25
+
26
+ def aws_account_from_id(id)
27
+ Aptible::Api::ExternalAwsAccount.find(id.to_s, token: fetch_token)
28
+ end
29
+
30
+ def ensure_external_aws_account(id)
31
+ acct = aws_account_from_id(id)
32
+ if acct.nil?
33
+ raise Thor::Error, "External AWS account not found: #{id}"
34
+ end
35
+
36
+ acct
37
+ end
38
+
39
+ def fetch_organization_id
40
+ orgs = Aptible::Auth::Organization.all(token: fetch_token)
41
+ raise Thor::Error, 'No organizations found, specify one with ' \
42
+ '--organization-id=ORG_ID' if orgs.empty?
43
+ raise Thor::Error, 'Multiple organizations found, indicate which ' \
44
+ 'one to use with --organization-id=ORG_ID ' \
45
+ "\n\tFound organization ids:" \
46
+ "\n\t\t#{orgs.map do |o|
47
+ "#{o.id} (#{o.name})"
48
+ end.join("\n\t\t")}" \
49
+ if orgs.count > 1
50
+
51
+ orgs.first.id
52
+ end
53
+
54
+ def organization_id_from_opts_or_auth(options)
55
+ return options[:organization_id] if options.key? :organization_id
56
+
57
+ fetch_organization_id
58
+ end
59
+
60
+ def build_external_aws_account_attrs(options)
61
+ discovery_role_arn = if options[:remove_discovery_role_arn]
62
+ ''
63
+ else
64
+ options[:discovery_role_arn]
65
+ end
66
+ discovery_enabled = if options.key?(:discovery_enabled)
67
+ options[:discovery_enabled]
68
+ end
69
+ attrs = {
70
+ account_name: options[:account_name] || options[:name],
71
+ aws_account_id: options[:aws_account_id],
72
+ aws_region_primary: options[:aws_region_primary],
73
+ status: options[:status],
74
+ discovery_enabled: discovery_enabled,
75
+ discovery_role_arn: discovery_role_arn,
76
+ discovery_frequency: options[:discovery_frequency]
77
+ }
78
+ attrs.reject { |_, v| v.nil? }
79
+ end
80
+
81
+ def create_external_aws_account!(options)
82
+ attrs = build_external_aws_account_attrs(options)
83
+ attrs[:organization_id] = organization_id_from_opts_or_auth(options)
84
+ begin
85
+ resource = Aptible::Api::ExternalAwsAccount.create(
86
+ token: fetch_token,
87
+ **attrs
88
+ )
89
+ if resource.errors.any?
90
+ raise Thor::Error, resource.errors.full_messages.first
91
+ end
92
+ resource
93
+ rescue HyperResource::ClientError => e
94
+ raise Thor::Error, e.message
95
+ end
96
+ end
97
+
98
+ def update_external_aws_account!(id, options)
99
+ ext = ensure_external_aws_account(id)
100
+ attrs = build_external_aws_account_attrs(options)
101
+ begin
102
+ unless attrs.empty?
103
+ ext.update!(**attrs)
104
+ if ext.errors.any?
105
+ raise Thor::Error, ext.errors.full_messages.first
106
+ end
107
+ end
108
+ ext
109
+ rescue HyperResource::ClientError => e
110
+ raise Thor::Error, e.message
111
+ end
112
+ end
113
+
114
+ def delete_external_aws_account!(id)
115
+ ext = ensure_external_aws_account(id)
116
+ begin
117
+ if ext.respond_to?(:destroy!)
118
+ ext.destroy!
119
+ elsif ext.respond_to?(:destroy)
120
+ ext.destroy
121
+ elsif ext.respond_to?(:delete!)
122
+ ext.delete!
123
+ elsif ext.respond_to?(:delete)
124
+ ext.delete
125
+ else
126
+ raise Thor::Error, 'Delete is not supported for this resource'
127
+ end
128
+ rescue HyperResource::ClientError => e
129
+ raise Thor::Error, e.message
130
+ end
131
+ true
132
+ end
133
+
134
+ def check_external_aws_account!(id)
135
+ ext = ensure_external_aws_account(id)
136
+ begin
137
+ ext.check!
138
+ rescue HyperResource::ClientError => e
139
+ raise Thor::Error, e.message
140
+ end
141
+ end
142
+
143
+ def format_check_state(state)
144
+ case state
145
+ when 'success'
146
+ '✅ success'
147
+ when 'failed'
148
+ '❌ failed'
149
+ when 'not_run'
150
+ '⏭️ not_run'
151
+ else
152
+ state
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -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
@@ -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.
@@ -203,8 +203,8 @@ module Aptible
203
203
  node.value('type', 'https')
204
204
  node.value('port', port)
205
205
  node.value('load_balancing_algorithm_type', vhost
206
- .load_balancing_algorithm_type)
207
- node.value('shared', vhost.shared)
206
+ .load_balancing_algorithm_type || 'round_robin')
207
+ node.value('shared', vhost.shared || 'false')
208
208
  end
209
209
 
210
210
  node.value('internal', vhost.internal)
@@ -65,7 +65,7 @@ module Aptible
65
65
  end
66
66
  end
67
67
 
68
- desc 'apps:scale SERVICE ' \
68
+ desc 'apps:scale [--app APP] SERVICE ' \
69
69
  '[--container-count COUNT] [--container-size SIZE_MB] ' \
70
70
  '[--container-profile PROFILE]',
71
71
  'Scale a service'
@@ -107,7 +107,7 @@ module Aptible
107
107
  attach_to_operation_logs(op)
108
108
  end
109
109
 
110
- desc 'apps:deprovision', 'Deprovision an app'
110
+ desc 'apps:deprovision [--app APP]', 'Deprovision an app'
111
111
  app_options
112
112
  define_method 'apps:deprovision' do
113
113
  telemetry(__method__, options)