aptible-cli 0.6.8 → 0.6.9

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 1a3f6b5e9a9f9f325832789f7bbac423de2276c8
4
- data.tar.gz: 328b06f1dcf9cb13c0df4f63f587fb4b79145a57
3
+ metadata.gz: c58858b887b9868e017e93d7248f4e7d1f898dc2
4
+ data.tar.gz: 7b2d52cc1b316205abb7d35c13722a32dccda29a
5
5
  SHA512:
6
- metadata.gz: 8618af8eaf81ca409c8dfd680a00744f265a72da22d87767d679b93c6deadc6d8edf2951fcd488521d68a92ffdbb60f2969a995fb5ad0f3c30afc0ee2858ff48
7
- data.tar.gz: 9da5825e8c2d8e2476ffce6268406f421cc357edd7fc8290801b1b342fe760351082f0f4864e782b113a0a1e8291b3226d815af3b50d23d0438cc0b97106e5d9
6
+ metadata.gz: 2c326bb7a9cc09e2689804e43b0fa00643eb759dbe838f572b0dc44df32caba6f6b76f1a14375e4aa6c1d5cac83457b8a37274c72f14ed843b6afcaa6acb2287
7
+ data.tar.gz: 07bd87aa8c414600d845ce133c868ad1a0df02e9c6518f6202717eabd7f12d8d7b95df42cc5a1f4177bb4f315e6950fc4fa6e13b64059ce2f12f8b0b0a6bf2c3
data/aptible-cli.gemspec CHANGED
@@ -31,4 +31,5 @@ Gem::Specification.new do |spec|
31
31
  spec.add_development_dependency 'rake'
32
32
  spec.add_development_dependency 'rspec', '~> 2.0'
33
33
  spec.add_development_dependency 'pry'
34
+ spec.add_development_dependency 'climate_control'
34
35
  end
@@ -8,6 +8,7 @@ require_relative 'helpers/environment'
8
8
  require_relative 'helpers/app'
9
9
  require_relative 'helpers/database'
10
10
  require_relative 'helpers/env'
11
+ require_relative 'helpers/tunnel'
11
12
 
12
13
  require_relative 'subcommands/apps'
13
14
  require_relative 'subcommands/config'
@@ -44,18 +44,6 @@ module Aptible
44
44
  say ''
45
45
  end
46
46
 
47
- def establish_connection(database, local_port)
48
- ENV['ACCESS_TOKEN'] = fetch_token
49
- ENV['APTIBLE_DATABASE'] = database.handle
50
-
51
- remote_port = claim_remote_port(database)
52
- ENV['TUNNEL_PORT'] = remote_port
53
-
54
- tunnel_args = "-L #{local_port}:localhost:#{remote_port}"
55
- command = "ssh #{tunnel_args} #{common_ssh_args(database)}"
56
- Kernel.exec(command)
57
- end
58
-
59
47
  def clone_database(source, dest_handle)
60
48
  op = source.create_operation(type: 'clone', handle: dest_handle)
61
49
  poll_for_success(op)
@@ -63,35 +51,33 @@ module Aptible
63
51
  databases_from_handle(dest_handle, source.account).first
64
52
  end
65
53
 
66
- def dump_database(database)
67
- execute_local_tunnel(database) do |url|
68
- filename = "#{database.handle}.dump"
69
- say "Dumping to #{filename}"
70
- `pg_dump #{url} > #{filename}`
54
+ # Creates a local tunnel and yields the helper
55
+
56
+ def with_local_tunnel(database, port = 0)
57
+ env = {
58
+ 'ACCESS_TOKEN' => fetch_token,
59
+ 'APTIBLE_DATABASE' => database.href
60
+ }
61
+ command = ['ssh', '-q'] + ssh_args(database)
62
+ Helpers::Tunnel.new(env, command).tap do |tunnel_helper|
63
+ tunnel_helper.start(port)
64
+ yield tunnel_helper
65
+ tunnel_helper.stop
71
66
  end
72
67
  end
73
68
 
74
- # Creates a local tunnel and yields the url to it
75
- def execute_local_tunnel(database)
76
- local_port = random_local_port
77
- pid = fork { establish_connection(database, local_port) }
78
-
79
- # TODO: Better test for connection readiness
80
- sleep 10
69
+ # Creates a local PG tunnel and yields the url to it
81
70
 
82
- auth = "aptible:#{database.passphrase}"
83
- host = "localhost:#{local_port}"
84
- yield "postgresql://#{auth}@#{host}/db"
85
- ensure
86
- Process.kill('HUP', pid) if pid
87
- end
71
+ def with_postgres_tunnel(database)
72
+ if database.type != 'postgresql'
73
+ fail Thor::Error, 'This command only works for PostgreSQL'
74
+ end
88
75
 
89
- def random_local_port
90
- # Allocate a dummy server to discover an available port
91
- dummy = TCPServer.new('127.0.0.1', 0)
92
- port = dummy.addr[1]
93
- dummy.close
94
- port
76
+ with_local_tunnel(database) do |tunnel_helper|
77
+ auth = "aptible:#{database.passphrase}"
78
+ host = "localhost:#{tunnel_helper.port}"
79
+ yield "postgresql://#{auth}@#{host}/db"
80
+ end
95
81
  end
96
82
 
97
83
  def local_url(database, local_port)
@@ -102,20 +88,15 @@ module Aptible
102
88
  "127.0.0.1:#{local_port}#{uri.path}"
103
89
  end
104
90
 
105
- def claim_remote_port(database)
106
- ENV['ACCESS_TOKEN'] = fetch_token
107
-
108
- `ssh #{common_ssh_args(database)} 2>/dev/null`.chomp
109
- end
110
-
111
- def common_ssh_args(database)
91
+ def ssh_args(database)
112
92
  host = database.account.bastion_host
113
93
  port = database.account.bastion_port
114
94
 
115
- opts = " -o 'SendEnv=*' -o StrictHostKeyChecking=no " \
116
- '-o UserKnownHostsFile=/dev/null'
117
- connection_args = "-p #{port} root@#{host}"
118
- "#{opts} #{connection_args}"
95
+ ['-o', 'SendEnv=APTIBLE_DATABASE',
96
+ '-o', 'SendEnv=ACCESS_TOKEN',
97
+ '-o', 'StrictHostKeyChecking=no',
98
+ '-o', 'UserKnownHostsFile=/dev/null',
99
+ '-p', port.to_s, "root@#{host}"]
119
100
  end
120
101
  end
121
102
  end
@@ -0,0 +1,76 @@
1
+ require 'socket'
2
+ require 'open3'
3
+
4
+ module Aptible
5
+ module CLI
6
+ module Helpers
7
+ class Tunnel
8
+ def initialize(env, cmd)
9
+ @env = env
10
+ @cmd = cmd
11
+ end
12
+
13
+ def start(desired_port = 0, err_fd = $stderr)
14
+ @local_port = desired_port
15
+ @local_port = random_local_port if @local_port == 0
16
+
17
+ # First, grab a remote port
18
+ out, err, status = Open3.capture3(@env, *@cmd)
19
+ fail "Failed to request remote port: #{err}" unless status.success?
20
+ remote_port = out.chomp
21
+
22
+ # Then, spin up a SSH session using that port and port forwarding
23
+ tunnel_env = @env.merge(
24
+ 'TUNNEL_PORT' => remote_port, # Request a specific port
25
+ 'TUNNEL_SIGNAL_OPEN' => '1' # Request signal when tunnel is up
26
+ )
27
+
28
+ tunnel_cmd = @cmd + [
29
+ '-L', "#{@local_port}:localhost:#{remote_port}",
30
+ '-o', 'SendEnv=TUNNEL_PORT',
31
+ '-o', 'SendEnv=TUNNEL_SIGNAL_OPEN'
32
+ ]
33
+
34
+ r_pipe, w_pipe = IO.pipe
35
+ @pid = Process.spawn(tunnel_env, *tunnel_cmd, in: :close,
36
+ out: w_pipe,
37
+ err: err_fd)
38
+
39
+ # Wait for the tunnel to come up before returning. The other end
40
+ # will send a message on stdout to indicate that the tunnel is ready.
41
+ w_pipe.close
42
+ begin
43
+ r_pipe.readline
44
+ rescue EOFError
45
+ raise 'Server closed the tunnel'
46
+ end
47
+ end
48
+
49
+ def stop
50
+ fail 'You must call #start before calling #stop' if @pid.nil?
51
+ Process.kill('HUP', @pid)
52
+ wait
53
+ end
54
+
55
+ def wait
56
+ Process.wait @pid
57
+ end
58
+
59
+ def port
60
+ fail 'You must call #start before calling #port!' if @local_port.nil?
61
+ @local_port
62
+ end
63
+
64
+ private
65
+
66
+ def random_local_port
67
+ # Allocate a dummy server to discover an available port
68
+ dummy = TCPServer.new('127.0.0.1', 0)
69
+ port = dummy.addr[1]
70
+ dummy.close
71
+ port
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -1,4 +1,5 @@
1
1
  require 'term/ansicolor'
2
+ require 'uri'
2
3
 
3
4
  module Aptible
4
5
  module CLI
@@ -50,15 +51,19 @@ module Aptible
50
51
  option :environment
51
52
  define_method 'db:dump' do |handle|
52
53
  database = ensure_database(options.merge(db: handle))
53
- dump_database(database)
54
+ with_postgres_tunnel(database) do |url|
55
+ filename = "#{handle}.dump"
56
+ say "Dumping to #{filename}"
57
+ `pg_dump #{url} > #{filename}`
58
+ end
54
59
  end
55
60
 
56
61
  desc 'db:execute HANDLE SQL_FILE', 'Executes sql against a database'
57
62
  option :environment
58
63
  define_method 'db:execute' do |handle, sql_path|
59
64
  database = ensure_database(options.merge(db: handle))
60
- execute_local_tunnel(database) do |url|
61
- say "Executing #{sql_path} against #{database.handle}"
65
+ with_postgres_tunnel(database) do |url|
66
+ say "Executing #{sql_path} against #{handle}"
62
67
  `psql #{url} < #{sql_path}`
63
68
  end
64
69
  end
@@ -67,21 +72,31 @@ module Aptible
67
72
  option :environment
68
73
  option :port, type: :numeric
69
74
  define_method 'db:tunnel' do |handle|
75
+ desired_port = Integer(options[:port] || 0)
70
76
  database = ensure_database(options.merge(db: handle))
71
- local_port = options[:port] || random_local_port
72
-
73
77
  say 'Creating tunnel...', :green
74
- say "Connect at #{local_url(database, local_port)}", :green
75
78
 
76
- uri = URI(local_url(database, local_port))
77
- db = uri.path.gsub(%r{^/}, '')
78
- say 'Or, use the following arguments:', :green
79
- say("* Host: #{uri.host}", :green)
80
- say("* Port: #{uri.port}", :green)
81
- say("* Username: #{uri.user}", :green) unless uri.user.empty?
82
- say("* Password: #{uri.password}", :green)
83
- say("* Database: #{db}", :green) unless db.empty?
84
- establish_connection(database, local_port)
79
+ with_local_tunnel(database, desired_port) do |tunnel_helper|
80
+ port = tunnel_helper.port
81
+ say "Connect at #{local_url(database, port)}", :green
82
+
83
+ uri = URI(local_url(database, port))
84
+ db = uri.path.gsub(%r{^/}, '')
85
+ say 'Or, use the following arguments:', :green
86
+ say("* Host: #{uri.host}", :green)
87
+ say("* Port: #{uri.port}", :green)
88
+ say("* Username: #{uri.user}", :green) unless uri.user.empty?
89
+ say("* Password: #{uri.password}", :green)
90
+ say("* Database: #{db}", :green) unless db.empty?
91
+
92
+ say 'Connected. Ctrl-C to close connection.'
93
+
94
+ begin
95
+ tunnel_helper.wait
96
+ rescue Interrupt
97
+ say 'Closing tunnel'
98
+ end
99
+ end
85
100
  end
86
101
 
87
102
  desc 'db:deprovision HANDLE', 'Deprovision a database'
@@ -22,7 +22,7 @@ module Aptible
22
22
  port = app.account.dumptruck_port
23
23
 
24
24
  ENV['ACCESS_TOKEN'] = fetch_token
25
- ENV['APTIBLE_APP'] = app.handle
25
+ ENV['APTIBLE_APP'] = app.href
26
26
  ENV['APTIBLE_CLI_COMMAND'] = 'logs'
27
27
 
28
28
  opts = " -o 'SendEnv=*' -o StrictHostKeyChecking=no " \
@@ -20,7 +20,7 @@ module Aptible
20
20
  port = app.account.dumptruck_port
21
21
 
22
22
  set_env('ACCESS_TOKEN', fetch_token)
23
- set_env('APTIBLE_APP', app.handle)
23
+ set_env('APTIBLE_APP', app.href)
24
24
  set_env('APTIBLE_CLI_COMMAND', 'ps')
25
25
 
26
26
  opts = " -o 'SendEnv=*' -o StrictHostKeyChecking=no " \
@@ -24,7 +24,7 @@ module Aptible
24
24
 
25
25
  ENV['ACCESS_TOKEN'] = fetch_token
26
26
  ENV['APTIBLE_COMMAND'] = command_from_args(*args)
27
- ENV['APTIBLE_APP'] = app.handle
27
+ ENV['APTIBLE_APP'] = app.href
28
28
 
29
29
  opts = options[:force_tty] ? '-t -t' : ''
30
30
  opts << " -o 'SendEnv=*' -o StrictHostKeyChecking=no " \
@@ -1,5 +1,5 @@
1
1
  module Aptible
2
2
  module CLI
3
- VERSION = '0.6.8'
3
+ VERSION = '0.6.9'
4
4
  end
5
5
  end
@@ -0,0 +1,71 @@
1
+ require 'spec_helper'
2
+ require 'climate_control'
3
+
4
+ describe Aptible::CLI::Helpers::Tunnel do
5
+ around do |example|
6
+ mocks_path = File.expand_path('../../../../mock', __FILE__)
7
+ path = "#{mocks_path}:#{ENV['PATH']}"
8
+ ClimateControl.modify PATH: path do
9
+ example.run
10
+ end
11
+ end
12
+
13
+ it 'reuses the port it was given' do
14
+ helper = described_class.new({}, ['ssh_mock.rb'])
15
+
16
+ r, w = IO.pipe
17
+ helper.start(0, w)
18
+ helper.stop
19
+
20
+ expect(r.readline.chomp).to eq('6')
21
+ expect(r.readline.chomp).to eq('-L')
22
+ expect(r.readline.chomp).to match(/\d+:localhost:1234$/)
23
+ expect(r.readline.chomp).to eq('-o')
24
+ expect(r.readline.chomp).to eq('SendEnv=TUNNEL_PORT')
25
+ expect(r.readline.chomp).to eq('-o')
26
+ expect(r.readline.chomp).to eq('SendEnv=TUNNEL_SIGNAL_OPEN')
27
+
28
+ r.close
29
+ w.close
30
+ end
31
+
32
+ it 'accepts a desired port' do
33
+ helper = described_class.new({}, ['ssh_mock.rb'])
34
+ r, w = IO.pipe
35
+ helper.start(5678, w)
36
+ helper.stop
37
+
38
+ expect(r.readline.chomp).to eq('6')
39
+ expect(r.readline.chomp).to eq('-L')
40
+ expect(r.readline.chomp).to eq('5678:localhost:1234')
41
+ expect(r.readline.chomp).to eq('-o')
42
+ expect(r.readline.chomp).to eq('SendEnv=TUNNEL_PORT')
43
+ expect(r.readline.chomp).to eq('-o')
44
+ expect(r.readline.chomp).to eq('SendEnv=TUNNEL_SIGNAL_OPEN')
45
+
46
+ r.close
47
+ w.close
48
+ end
49
+
50
+ it 'captures and displays port discovery errors' do
51
+ helper = described_class.new({ 'FAIL_PORT' => '1' }, ['ssh_mock.rb'])
52
+ expect { helper.start }.to raise_error(/Something went wrong/)
53
+ end
54
+
55
+ it 'captures and displays tunnel errors' do
56
+ helper = described_class.new({ 'FAIL_TUNNEL' => '1' }, ['ssh_mock.rb'])
57
+ expect do
58
+ helper.start(0, File.open(File::NULL, 'w'))
59
+ end.to raise_error(/Server closed the tunnel/)
60
+ end
61
+
62
+ it 'should fail if #port is called before #start' do
63
+ socat = described_class.new({}, [])
64
+ expect { socat.port }.to raise_error(/You must call #start/)
65
+ end
66
+
67
+ it 'should fail if #stop is called before #start' do
68
+ socat = described_class.new({}, [])
69
+ expect { socat.stop }.to raise_error(/You must call #start/)
70
+ end
71
+ end
@@ -7,12 +7,13 @@ end
7
7
  class Account < OpenStruct
8
8
  end
9
9
 
10
+ class SocatHelperMock < OpenStruct
11
+ end
12
+
10
13
  describe Aptible::CLI::Agent do
11
14
  before { subject.stub(:ask) }
12
15
  before { subject.stub(:save_token) }
13
16
  before { subject.stub(:fetch_token) { double 'token' } }
14
- before { subject.stub(:random_local_port) { 4242 } }
15
- before { subject.stub(:establish_connection) }
16
17
 
17
18
  let(:account) do
18
19
  Account.new(bastion_host: 'localhost',
@@ -24,7 +25,14 @@ describe Aptible::CLI::Agent do
24
25
  type: 'postgresql',
25
26
  handle: 'foobar',
26
27
  passphrase: 'password',
27
- connection_url: 'postgresql://aptible:password@10.252.1.125:49158/db'
28
+ connection_url: 'postgresql://aptible:password@10.252.1.125:49158/db',
29
+ account: account
30
+ )
31
+ end
32
+
33
+ let(:socat_helper) do
34
+ SocatHelperMock.new(
35
+ port: 4242
28
36
  )
29
37
  end
30
38
 
@@ -39,11 +47,14 @@ describe Aptible::CLI::Agent do
39
47
  it 'should print a message about how to connect' do
40
48
  allow(Aptible::Api::Database).to receive(:all) { [database] }
41
49
  local_url = 'postgresql://aptible:password@127.0.0.1:4242/db'
50
+
51
+ expect(subject).to receive(:with_local_tunnel).with(database, 0)
52
+ .and_yield(socat_helper)
42
53
  expect(subject).to receive(:say).with('Creating tunnel...', :green)
43
54
  expect(subject).to receive(:say).with("Connect at #{local_url}", :green)
44
55
 
45
56
  # db:tunnel should also explain each component of the URL:
46
- expect(subject).to receive(:say).exactly(6).times
57
+ expect(subject).to receive(:say).exactly(7).times
47
58
  subject.send('db:tunnel', 'foobar')
48
59
  end
49
60
  end
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Emulate server behavior
4
+
5
+ if ENV['TUNNEL_PORT']
6
+ fail 'Something went wrong!' if ENV['FAIL_TUNNEL']
7
+ puts 'TUNNEL READY'
8
+ else
9
+ fail 'Something went wrong!' if ENV['FAIL_PORT']
10
+ puts 1234
11
+ end
12
+
13
+ # Log to stderr so we can collect in test
14
+
15
+ $stderr.puts ARGV.size
16
+ ARGV.each do |a|
17
+ $stderr.puts a
18
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aptible-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.8
4
+ version: 0.6.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Frank Macreery
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-05-18 00:00:00.000000000 Z
11
+ date: 2016-05-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: aptible-api
@@ -150,6 +150,20 @@ dependencies:
150
150
  - - ">="
151
151
  - !ruby/object:Gem::Version
152
152
  version: '0'
153
+ - !ruby/object:Gem::Dependency
154
+ name: climate_control
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
153
167
  description: Aptible CLI
154
168
  email:
155
169
  - frank@macreery.com
@@ -176,6 +190,7 @@ files:
176
190
  - lib/aptible/cli/helpers/environment.rb
177
191
  - lib/aptible/cli/helpers/operation.rb
178
192
  - lib/aptible/cli/helpers/token.rb
193
+ - lib/aptible/cli/helpers/tunnel.rb
179
194
  - lib/aptible/cli/subcommands/apps.rb
180
195
  - lib/aptible/cli/subcommands/config.rb
181
196
  - lib/aptible/cli/subcommands/db.rb
@@ -188,11 +203,13 @@ files:
188
203
  - lib/aptible/cli/version.rb
189
204
  - spec/aptible/cli/agent_spec.rb
190
205
  - spec/aptible/cli/helpers/handle_from_git_remote.rb
206
+ - spec/aptible/cli/helpers/tunnel_spec.rb
191
207
  - spec/aptible/cli/subcommands/apps_spec.rb
192
208
  - spec/aptible/cli/subcommands/db_spec.rb
193
209
  - spec/aptible/cli/subcommands/domains_spec.rb
194
210
  - spec/aptible/cli/subcommands/logs_spec.rb
195
211
  - spec/aptible/cli/subcommands/ps_spec.rb
212
+ - spec/mock/ssh_mock.rb
196
213
  - spec/spec_helper.rb
197
214
  homepage: https://github.com/aptible/aptible-cli
198
215
  licenses:
@@ -221,9 +238,11 @@ summary: Command-line interface for Aptible services
221
238
  test_files:
222
239
  - spec/aptible/cli/agent_spec.rb
223
240
  - spec/aptible/cli/helpers/handle_from_git_remote.rb
241
+ - spec/aptible/cli/helpers/tunnel_spec.rb
224
242
  - spec/aptible/cli/subcommands/apps_spec.rb
225
243
  - spec/aptible/cli/subcommands/db_spec.rb
226
244
  - spec/aptible/cli/subcommands/domains_spec.rb
227
245
  - spec/aptible/cli/subcommands/logs_spec.rb
228
246
  - spec/aptible/cli/subcommands/ps_spec.rb
247
+ - spec/mock/ssh_mock.rb
229
248
  - spec/spec_helper.rb