aptible-cli 0.10.0 → 0.11.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +5 -0
- data/README.md +34 -30
- data/aptible-cli.gemspec +3 -3
- data/lib/aptible/cli/agent.rb +2 -0
- data/lib/aptible/cli/helpers/app.rb +15 -0
- data/lib/aptible/cli/helpers/operation.rb +5 -2
- data/lib/aptible/cli/helpers/ssh.rb +74 -23
- data/lib/aptible/cli/subcommands/config.rb +5 -2
- data/lib/aptible/cli/subcommands/deploy.rb +89 -0
- data/lib/aptible/cli/subcommands/logs.rb +1 -1
- data/lib/aptible/cli/subcommands/ps.rb +1 -1
- data/lib/aptible/cli/subcommands/ssh.rb +1 -1
- data/lib/aptible/cli/version.rb +1 -1
- data/script/sync-readme-usage +34 -0
- data/spec/aptible/cli/helpers/ssh_spec.rb +121 -0
- data/spec/aptible/cli/subcommands/apps_spec.rb +28 -0
- data/spec/aptible/cli/subcommands/deploy_spec.rb +158 -0
- data/spec/aptible/cli/subcommands/logs_spec.rb +2 -2
- data/spec/aptible/cli/subcommands/ssh_spec.rb +5 -5
- data/spec/script/exit-with +2 -0
- data/spec/script/pid-signal +4 -0
- data/spec/script/setpgid +3 -0
- data/spec/script/ssh-spawn +4 -0
- metadata +23 -11
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4caf3814e0cffe6799a62625c5d9fb9f79dbd016
|
4
|
+
data.tar.gz: dbe1d0e306d35ce276c4ac0d323d0e1ea706aba4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1149e64d3194970dd488a36519eb07d1783d9ef860b587bd04f844de452afb848980ee4340e2e6a2cef0a10afa148812e48fdce7d9f18c296d072e52da5d45f0
|
7
|
+
data.tar.gz: 7aed71d453418b1d887d214974a6e60903338154c866d1a5ec90e59ab185f0ad02d0e06bc929046bc1290234572c3785c60fa5ac50c91498f9c6513f628a0127
|
data/.travis.yml
CHANGED
data/README.md
CHANGED
@@ -26,39 +26,43 @@ And then run `bundle install`.
|
|
26
26
|
|
27
27
|
From `aptible help`:
|
28
28
|
|
29
|
+
<!-- BEGIN USAGE -->
|
29
30
|
```
|
30
31
|
Commands:
|
31
|
-
aptible apps
|
32
|
-
aptible apps:create HANDLE
|
33
|
-
aptible apps:deprovision
|
34
|
-
aptible apps:scale SERVICE [--container-count COUNT] [--container-size
|
35
|
-
aptible backup:list DB_HANDLE
|
36
|
-
aptible backup:restore [--handle HANDLE] [--size SIZE_GB]
|
37
|
-
aptible config
|
38
|
-
aptible config:add
|
39
|
-
aptible config:rm
|
40
|
-
aptible config:set
|
41
|
-
aptible config:unset
|
42
|
-
aptible db:backup HANDLE
|
43
|
-
aptible db:clone SOURCE DEST
|
44
|
-
aptible db:create HANDLE
|
45
|
-
aptible db:deprovision HANDLE
|
46
|
-
aptible db:dump HANDLE
|
47
|
-
aptible db:execute HANDLE SQL_FILE
|
48
|
-
aptible db:list
|
49
|
-
aptible db:reload HANDLE
|
50
|
-
aptible db:
|
51
|
-
aptible
|
52
|
-
aptible
|
53
|
-
aptible
|
54
|
-
aptible
|
55
|
-
aptible
|
56
|
-
aptible
|
57
|
-
aptible
|
58
|
-
aptible
|
59
|
-
aptible
|
60
|
-
aptible
|
32
|
+
aptible apps # List all applications
|
33
|
+
aptible apps:create HANDLE # Create a new application
|
34
|
+
aptible apps:deprovision # Deprovision an app
|
35
|
+
aptible apps:scale SERVICE [--container-count COUNT] [--container-size SIZE_MB] # Scale a service
|
36
|
+
aptible backup:list DB_HANDLE # List backups for a database
|
37
|
+
aptible backup:restore BACKUP_ID [--handle HANDLE] [--container-size SIZE_MB] [--size SIZE_GB] # Restore a backup
|
38
|
+
aptible config # Print an app's current configuration
|
39
|
+
aptible config:add # Add an ENV variable to an app
|
40
|
+
aptible config:rm # Remove an ENV variable from an app
|
41
|
+
aptible config:set # Alias for config:add
|
42
|
+
aptible config:unset # Alias for config:rm
|
43
|
+
aptible db:backup HANDLE # Backup a database
|
44
|
+
aptible db:clone SOURCE DEST # Clone a database to create a new one
|
45
|
+
aptible db:create HANDLE[--type TYPE] [--container-size SIZE_MB] [--size SIZE_GB] # Create a new database
|
46
|
+
aptible db:deprovision HANDLE # Deprovision a database
|
47
|
+
aptible db:dump HANDLE # Dump a remote database to file
|
48
|
+
aptible db:execute HANDLE SQL_FILE # Executes sql against a database
|
49
|
+
aptible db:list # List all databases
|
50
|
+
aptible db:reload HANDLE # Reload a database
|
51
|
+
aptible db:restart HANDLE [--container-size SIZE_MB] [--size SIZE_GB] # Restart a database
|
52
|
+
aptible db:tunnel HANDLE # Create a local tunnel to a database
|
53
|
+
aptible deploy [OPTIONS] [VAR1=VAL1] [VAR=VAL2] ... # Deploy an app
|
54
|
+
aptible domains # Print an app's current virtual domains
|
55
|
+
aptible help [COMMAND] # Describe available commands or one specific command
|
56
|
+
aptible login # Log in to Aptible
|
57
|
+
aptible logs # Follows logs from a running app or database
|
58
|
+
aptible operation:cancel OPERATION_ID # Cancel a running operation
|
59
|
+
aptible ps # Display running processes for an app - DEPRECATED
|
60
|
+
aptible rebuild # Rebuild an app, and restart its services
|
61
|
+
aptible restart # Restart all services associated with an app
|
62
|
+
aptible ssh [COMMAND] # Run a command against an app
|
63
|
+
aptible version # Print Aptible CLI version
|
61
64
|
```
|
65
|
+
<!-- END USAGE -->
|
62
66
|
|
63
67
|
## Contributing
|
64
68
|
|
data/aptible-cli.gemspec
CHANGED
@@ -20,9 +20,9 @@ Gem::Specification.new do |spec|
|
|
20
20
|
spec.test_files = spec.files.grep(%r{spec/})
|
21
21
|
spec.require_paths = ['lib']
|
22
22
|
|
23
|
-
spec.add_dependency 'aptible-
|
24
|
-
spec.add_dependency 'aptible-
|
25
|
-
spec.add_dependency 'aptible-
|
23
|
+
spec.add_dependency 'aptible-resource', '~> 0.4.0'
|
24
|
+
spec.add_dependency 'aptible-api', '~> 0.9.27'
|
25
|
+
spec.add_dependency 'aptible-auth', '~> 0.11.13'
|
26
26
|
spec.add_dependency 'thor', '~> 0.19.1'
|
27
27
|
spec.add_dependency 'git'
|
28
28
|
spec.add_dependency 'term-ansicolor'
|
data/lib/aptible/cli/agent.rb
CHANGED
@@ -20,6 +20,7 @@ require_relative 'subcommands/domains'
|
|
20
20
|
require_relative 'subcommands/logs'
|
21
21
|
require_relative 'subcommands/ps'
|
22
22
|
require_relative 'subcommands/rebuild'
|
23
|
+
require_relative 'subcommands/deploy'
|
23
24
|
require_relative 'subcommands/restart'
|
24
25
|
require_relative 'subcommands/ssh'
|
25
26
|
require_relative 'subcommands/backup'
|
@@ -40,6 +41,7 @@ module Aptible
|
|
40
41
|
include Subcommands::Logs
|
41
42
|
include Subcommands::Ps
|
42
43
|
include Subcommands::Rebuild
|
44
|
+
include Subcommands::Deploy
|
43
45
|
include Subcommands::Restart
|
44
46
|
include Subcommands::SSH
|
45
47
|
include Subcommands::Backup
|
@@ -140,6 +140,21 @@ module Aptible
|
|
140
140
|
end.select { |a| a.handle == handle }
|
141
141
|
end
|
142
142
|
|
143
|
+
def extract_env(args)
|
144
|
+
Hash[args.map do |arg|
|
145
|
+
k, v = arg.split('=', 2)
|
146
|
+
validate_env_key!(k)
|
147
|
+
[k, v]
|
148
|
+
end]
|
149
|
+
end
|
150
|
+
|
151
|
+
def validate_env_key!(k)
|
152
|
+
# Keys that start with '-' are likely to be mispelled options. As of
|
153
|
+
# May 2017 (> 3 years of Aptible!), there are only 2 such cases, both
|
154
|
+
# of which are indeed mispelled options.
|
155
|
+
raise Thor::Error, "Invalid argument: #{k}" if k.start_with?('-')
|
156
|
+
end
|
157
|
+
|
143
158
|
private
|
144
159
|
|
145
160
|
def handle_strategies
|
@@ -28,7 +28,7 @@ module Aptible
|
|
28
28
|
# connecting for. There might be ways to make this better.
|
29
29
|
ENV['ACCESS_TOKEN'] = fetch_token
|
30
30
|
|
31
|
-
|
31
|
+
code = connect_to_ssh_portal(
|
32
32
|
operation,
|
33
33
|
'-o', 'SendEnv=ACCESS_TOKEN'
|
34
34
|
)
|
@@ -36,7 +36,10 @@ module Aptible
|
|
36
36
|
# If the portal is down, fall back to polling for success. If the
|
37
37
|
# operation failed, poll_for_success will immediately fall through to
|
38
38
|
# the error message.
|
39
|
-
|
39
|
+
unless code == 0
|
40
|
+
puts 'Disconnected from logs, waiting for operation to complete'
|
41
|
+
poll_for_success(operation)
|
42
|
+
end
|
40
43
|
end
|
41
44
|
|
42
45
|
def cancel_operation(operation)
|
@@ -3,33 +3,50 @@ module Aptible
|
|
3
3
|
module Helpers
|
4
4
|
module Ssh
|
5
5
|
def connect_to_ssh_portal(operation, *extra_ssh_args)
|
6
|
+
# NOTE: This is a little tricky to get rigt, so before you make any
|
7
|
+
# changes, read this.
|
8
|
+
#
|
9
|
+
# - The first gotcha is that we cannot use Kernel.exec here, because
|
10
|
+
# we need to perform cleanup when exiting from
|
11
|
+
# operation#with_ssh_cmd.
|
12
|
+
#
|
13
|
+
# - The second gotcha is that we need to somehow capture the exit
|
14
|
+
# status, so that CLI commands that call the SSH portal can proxy
|
15
|
+
# this back to their own caller (the most important one here is
|
16
|
+
# aptible ssh).
|
17
|
+
#
|
18
|
+
# To do this, we have to handle interrutps as a signal, as opposed to
|
19
|
+
# handle an Interrupt exception. The reason for this has to do with
|
20
|
+
# how Ruby's wait is implemented (this happens in process.c's
|
21
|
+
# rb_waitpid). There are two main considerations here:
|
22
|
+
#
|
23
|
+
# - It automatically resumes when it receives EINTR, so our control
|
24
|
+
# is pretty high-level here.
|
25
|
+
# - It handles interrupts prior to setting $? (this appears to have
|
26
|
+
# changed between Ruby 2.2 and 2.3, perhaps the newer implementation
|
27
|
+
# behaves differently).
|
28
|
+
#
|
29
|
+
# Unfortunately, this means that if we receive SIGINT while in
|
30
|
+
# Process::wait2, then we never get access to SSH's exitstatus: Ruby
|
31
|
+
# throws a Interrupt so we don't have a return value, and it doesn't
|
32
|
+
# set $?, so we can't read it back there.
|
33
|
+
#
|
34
|
+
# Of course, we can't just call Proces::wait2 again, because at this
|
35
|
+
# point, we've reaped our child.
|
36
|
+
#
|
37
|
+
# To solve this, we add our own signal handler on SIGINT, which
|
38
|
+
# simply proxies SIGINT to SSH if we happen to have a different
|
39
|
+
# process group (which shouldn't be the case), just to be safe and
|
40
|
+
# let users exit the CLI.
|
6
41
|
with_ssh_cmd(operation) do |base_ssh_cmd|
|
7
|
-
|
8
|
-
begin
|
9
|
-
Kernel.system(*ssh_cmd)
|
10
|
-
rescue Interrupt
|
11
|
-
# Assuming we have a TTY, there are two cases here. Either SSH
|
12
|
-
# itself has a TTY, in which case it is controlling the TTY and
|
13
|
-
# the CLI won't be receiving SIGINT when CTRL+C is pressed, or
|
14
|
-
# SSH has no TTY, in which case the CLI and SSH are sharing the
|
15
|
-
# same process group, and will both receive SIGINT when CTRL+C
|
16
|
-
# is pressed and exit accordingly.
|
17
|
-
#
|
18
|
-
# I'm not sure how this *should* work on Windows, but it appears
|
19
|
-
# to work pretty much the same way, except that we'll get an ugly
|
20
|
-
# "Terminate batch job (Y/N)?" prompt in the no-TTY-for-SSH case,
|
21
|
-
# which we're likely to have a hard time handling.
|
22
|
-
#
|
23
|
-
# Note that this DOES NOT handle the case where the CLI is sent
|
24
|
-
# SIGINT by another process (as opposed to the line discipline).
|
25
|
-
# In this case, SSH will continue running in the background. This
|
26
|
-
# is something we should fix, but for now this 'simple' fix is
|
27
|
-
# enough to addresses the ugly stack trace we show when
|
28
|
-
# CTRL+C'ing out of logs.
|
29
|
-
end
|
42
|
+
spawn_passthrough(base_ssh_cmd + extra_ssh_args)
|
30
43
|
end
|
31
44
|
end
|
32
45
|
|
46
|
+
def exit_with_ssh_portal(*args)
|
47
|
+
exit connect_to_ssh_portal(*args)
|
48
|
+
end
|
49
|
+
|
33
50
|
def with_ssh_cmd(operation)
|
34
51
|
ensure_ssh_dir!
|
35
52
|
ensure_config!
|
@@ -42,6 +59,40 @@ module Aptible
|
|
42
59
|
|
43
60
|
private
|
44
61
|
|
62
|
+
def spawn_passthrough(command)
|
63
|
+
redirection = { in: :in, out: :out, err: :err, close_others: true }
|
64
|
+
pid = Process.spawn(*command, redirection)
|
65
|
+
|
66
|
+
reset = Signal.trap('SIGINT') do
|
67
|
+
# FIXME: If we're on Windows, we don't really know whether SSH
|
68
|
+
# received SIGINT or not, so for now, we just ignore it.
|
69
|
+
next if Gem.win_platform?
|
70
|
+
|
71
|
+
begin
|
72
|
+
# SSH should be running in our process group, which means that
|
73
|
+
# if the user sends CTRL+C, we'll both receive it. In this
|
74
|
+
# case, just ignore the signal and let SSH handle it.
|
75
|
+
next if Process.getpgid(Process.pid) == Process.getpgid(pid)
|
76
|
+
|
77
|
+
# If we get here, then oddly, SSH is not running in our process
|
78
|
+
# group and yet we got the signal. In this case, let's simply
|
79
|
+
# ignore it.
|
80
|
+
Process.kill(:SIGINT, pid)
|
81
|
+
rescue Errno::ESRCH
|
82
|
+
# This could happen if SSH exited after receiving the SIGINT,
|
83
|
+
# Ruby waited it, then ran our signal handler. In this case, we
|
84
|
+
# don't need to do anything, so we proceed.
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
begin
|
89
|
+
_, status = Process.wait2(pid)
|
90
|
+
return status.exited? ? status.exitstatus : 128 + status.termsig
|
91
|
+
ensure
|
92
|
+
Signal.trap('SIGINT', reset)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
45
96
|
def ensure_ssh_dir!
|
46
97
|
FileUtils.mkdir_p(ssh_dir, mode: 0o700)
|
47
98
|
end
|
@@ -23,7 +23,7 @@ module Aptible
|
|
23
23
|
define_method 'config:add' do |*args|
|
24
24
|
# FIXME: define_method - ?! Seriously, WTF Thor.
|
25
25
|
app = ensure_app(options)
|
26
|
-
env =
|
26
|
+
env = extract_env(args)
|
27
27
|
operation = app.create_operation!(type: 'configure', env: env)
|
28
28
|
puts 'Updating configuration and restarting app...'
|
29
29
|
attach_to_operation_logs(operation)
|
@@ -40,7 +40,10 @@ module Aptible
|
|
40
40
|
define_method 'config:rm' do |*args|
|
41
41
|
# FIXME: define_method - ?! Seriously, WTF Thor.
|
42
42
|
app = ensure_app(options)
|
43
|
-
env = Hash[args.map
|
43
|
+
env = Hash[args.map do |arg|
|
44
|
+
validate_env_key!(arg)
|
45
|
+
[arg, '']
|
46
|
+
end]
|
44
47
|
operation = app.create_operation!(type: 'configure', env: env)
|
45
48
|
puts 'Updating configuration and restarting app...'
|
46
49
|
attach_to_operation_logs(operation)
|
@@ -0,0 +1,89 @@
|
|
1
|
+
module Aptible
|
2
|
+
module CLI
|
3
|
+
module Subcommands
|
4
|
+
module Deploy
|
5
|
+
DOCKER_IMAGE_DEPLOY_ARGS = Hash[%w(
|
6
|
+
APTIBLE_DOCKER_IMAGE
|
7
|
+
APTIBLE_PRIVATE_REGISTRY_EMAIL
|
8
|
+
APTIBLE_PRIVATE_REGISTRY_USERNAME
|
9
|
+
APTIBLE_PRIVATE_REGISTRY_PASSWORD
|
10
|
+
).map do |var|
|
11
|
+
opt = var.gsub(/^APTIBLE_/, '').downcase.to_sym
|
12
|
+
[opt, var]
|
13
|
+
end]
|
14
|
+
|
15
|
+
NULL_SHA1 = '0000000000000000000000000000000000000000'.freeze
|
16
|
+
|
17
|
+
def self.included(thor)
|
18
|
+
thor.class_eval do
|
19
|
+
include Helpers::Operation
|
20
|
+
include Helpers::App
|
21
|
+
|
22
|
+
desc 'deploy [OPTIONS] [VAR1=VAL1] [VAR=VAL2] ...', 'Deploy an app'
|
23
|
+
option :git_commitish,
|
24
|
+
desc: 'Deploy a specific git commit or branch: the ' \
|
25
|
+
'commitish must have been pushed to Aptible beforehand'
|
26
|
+
option :git_detach,
|
27
|
+
type: :boolean, default: false,
|
28
|
+
desc: 'Detach this app from its git repository: ' \
|
29
|
+
'its Procfile, Dockerfile, and .aptible.yml will be ' \
|
30
|
+
'ignored until you deploy again with git'
|
31
|
+
DOCKER_IMAGE_DEPLOY_ARGS.each_pair do |opt, var|
|
32
|
+
option opt,
|
33
|
+
type: :string, banner: var,
|
34
|
+
desc: "Shorthand for #{var}=..."
|
35
|
+
end
|
36
|
+
app_options
|
37
|
+
def deploy(*args)
|
38
|
+
app = ensure_app(options)
|
39
|
+
|
40
|
+
git_ref = options[:git_commitish]
|
41
|
+
if options[:git_detach]
|
42
|
+
if git_ref
|
43
|
+
raise Thor::Error, 'The options --git-committish and ' \
|
44
|
+
'--git-detach are incompatible'
|
45
|
+
end
|
46
|
+
git_ref = NULL_SHA1
|
47
|
+
end
|
48
|
+
|
49
|
+
env = extract_env(args)
|
50
|
+
|
51
|
+
DOCKER_IMAGE_DEPLOY_ARGS.each_pair do |opt, var|
|
52
|
+
val = options[opt]
|
53
|
+
next unless val
|
54
|
+
if env[var] && env[var] != val
|
55
|
+
dasherized = "--#{opt.to_s.tr('_', '-')}"
|
56
|
+
raise Thor::Error, "The options #{dasherized} and #{var} " \
|
57
|
+
'cannot be set to different values'
|
58
|
+
end
|
59
|
+
env[var] = val
|
60
|
+
end
|
61
|
+
|
62
|
+
opts = {
|
63
|
+
type: 'deploy',
|
64
|
+
env: env,
|
65
|
+
git_ref: git_ref
|
66
|
+
}.delete_if { |_, v| v.nil? || v.empty? }
|
67
|
+
|
68
|
+
allow_it = [
|
69
|
+
opts[:git_ref],
|
70
|
+
opts[:env].try(:[], 'APTIBLE_DOCKER_IMAGE'),
|
71
|
+
app.status == 'provisioned'
|
72
|
+
].any? { |x| x }
|
73
|
+
|
74
|
+
unless allow_it
|
75
|
+
m = 'You need to deploy either from git or a Docker image'
|
76
|
+
raise Thor::Error, m
|
77
|
+
end
|
78
|
+
|
79
|
+
operation = app.create_operation!(opts)
|
80
|
+
|
81
|
+
puts 'Deploying app...'
|
82
|
+
attach_to_operation_logs(operation)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
@@ -34,7 +34,7 @@ module Aptible
|
|
34
34
|
op = resource.create_operation!(type: 'logs', status: 'succeeded')
|
35
35
|
|
36
36
|
ENV['ACCESS_TOKEN'] = fetch_token
|
37
|
-
|
37
|
+
exit_with_ssh_portal(op, '-o', 'SendEnv=ACCESS_TOKEN', '-T')
|
38
38
|
end
|
39
39
|
end
|
40
40
|
end
|
data/lib/aptible/cli/version.rb
CHANGED
@@ -0,0 +1,34 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require 'open3'
|
3
|
+
|
4
|
+
USAGE = ARGV.fetch(0)
|
5
|
+
|
6
|
+
puts "Sync CLI usage in #{USAGE}"
|
7
|
+
|
8
|
+
txt, err, status = Open3.capture3(
|
9
|
+
{ 'THOR_COLUMNS' => '1000' },
|
10
|
+
'aptible', 'help'
|
11
|
+
)
|
12
|
+
|
13
|
+
raise "Failed to extract usage: #{err}" unless status.success?
|
14
|
+
|
15
|
+
usage = "```\n#{txt.gsub(/^$\n/, '')}```\n"
|
16
|
+
|
17
|
+
bits = []
|
18
|
+
|
19
|
+
File.open(USAGE) do |f|
|
20
|
+
in_usage = false
|
21
|
+
|
22
|
+
f.each_line do |l|
|
23
|
+
in_usage = false if l.include?('END USAGE')
|
24
|
+
|
25
|
+
bits << l unless in_usage
|
26
|
+
|
27
|
+
if l.include?('BEGIN USAGE')
|
28
|
+
in_usage = true
|
29
|
+
bits << usage
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
File.write(USAGE, bits.join(''))
|
@@ -89,4 +89,125 @@ describe Aptible::CLI::Helpers::Ssh do
|
|
89
89
|
expect(has_yielded).to be_truthy
|
90
90
|
end
|
91
91
|
end
|
92
|
+
|
93
|
+
describe '#spawn_passthrough' do
|
94
|
+
let(:bins) { File.expand_path('../../../../script', __FILE__) }
|
95
|
+
let(:ruby) { Gem.win_platform? ? 'ruby.exe' : 'ruby' }
|
96
|
+
let(:wrapper) { [ruby, File.join(bins, 'ssh-spawn')] }
|
97
|
+
let(:exit_with) { [ruby, File.join(bins, 'exit-with')] }
|
98
|
+
let(:sigint) { [ruby, File.join(bins, 'pid-signal')] }
|
99
|
+
let(:setpgid) { [ruby, File.join(bins, 'setpgid')] }
|
100
|
+
|
101
|
+
let(:cleanup) { [] }
|
102
|
+
|
103
|
+
after do
|
104
|
+
cleanup.each do |pid|
|
105
|
+
begin
|
106
|
+
Process.kill(:SIGKILL, -pid)
|
107
|
+
rescue Errno::ESRCH, Errno::EINVAL
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def spawn_with_cleanup(*args)
|
113
|
+
kw = Gem.win_platform? ? { new_pgroup: true } : { pgroup: true }
|
114
|
+
Process.spawn(*args, **kw).tap { |pid| cleanup << pid }
|
115
|
+
end
|
116
|
+
|
117
|
+
def wait_for_file(file)
|
118
|
+
50.times do
|
119
|
+
return if File.exist?(file)
|
120
|
+
sleep 0.1
|
121
|
+
end
|
122
|
+
|
123
|
+
raise "File never showed up: #{file}"
|
124
|
+
end
|
125
|
+
|
126
|
+
def wait_for_pid(pid, timeout = 5)
|
127
|
+
(timeout * 10).times do
|
128
|
+
_, status = Process.wait2(pid, Process::WNOHANG)
|
129
|
+
return status if status
|
130
|
+
sleep 0.1
|
131
|
+
end
|
132
|
+
|
133
|
+
raise "PID never exited: #{pid}"
|
134
|
+
end
|
135
|
+
|
136
|
+
[0, 1].each do |c|
|
137
|
+
it "returns the command exit code (#{c})" do
|
138
|
+
pid = spawn_with_cleanup(*wrapper, *exit_with, c.to_s)
|
139
|
+
status = wait_for_pid(pid)
|
140
|
+
expect(status.exitstatus).to eq(c)
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
context 'signals' do
|
145
|
+
# Don't run these on Windows: sending SIGINT will send it to the entire
|
146
|
+
# console group, which includes the process running the specs.
|
147
|
+
before { skip 'Windows' if Gem.win_platform? }
|
148
|
+
|
149
|
+
it 'returns 128 + signal number when signalled' do
|
150
|
+
Dir.mktmpdir do |dir|
|
151
|
+
pid_file = File.join(dir, 'pid')
|
152
|
+
pid = spawn_with_cleanup(*wrapper, *sigint, pid_file)
|
153
|
+
wait_for_file(pid_file)
|
154
|
+
|
155
|
+
child_pid = Integer(File.read(pid_file).chomp)
|
156
|
+
Process.kill('INT', child_pid)
|
157
|
+
|
158
|
+
status = wait_for_pid(pid)
|
159
|
+
|
160
|
+
if Gem.win_platform?
|
161
|
+
expect(status.exitstatus).not_to eq(0)
|
162
|
+
else
|
163
|
+
expect(status.exitstatus).to eq(128 + Signal.list.fetch('INT'))
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
it 'does not proxy SIGINT when part of the same process group' do
|
169
|
+
Dir.mktmpdir do |dir|
|
170
|
+
pid_file = File.join(dir, 'pid')
|
171
|
+
pid = spawn_with_cleanup(*wrapper, *sigint, pid_file)
|
172
|
+
wait_for_file(pid_file)
|
173
|
+
|
174
|
+
child_pid = Integer(File.read(pid_file).chomp)
|
175
|
+
expect(Process.getpgid(child_pid)).to eq(Process.getpgid(pid))
|
176
|
+
Process.kill('INT', pid)
|
177
|
+
|
178
|
+
expect { wait_for_pid(pid, 2) }.to raise_error(/never exited/im)
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
it 'proxies SIGINT when process groups are different' do
|
183
|
+
Dir.mktmpdir do |dir|
|
184
|
+
pid_file = File.join(dir, 'pid')
|
185
|
+
pid = spawn_with_cleanup(*wrapper, *setpgid, *sigint, pid_file)
|
186
|
+
wait_for_file(pid_file)
|
187
|
+
|
188
|
+
child_pid = Integer(File.read(pid_file).chomp)
|
189
|
+
expect(Process.getpgid(child_pid)).not_to eq(Process.getpgid(pid))
|
190
|
+
Process.kill('INT', pid)
|
191
|
+
|
192
|
+
status = wait_for_pid(pid)
|
193
|
+
expect(status.exitstatus).to eq(128 + Signal.list.fetch('INT'))
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
it 'does not crash when receiving SIGINT concurrently' do
|
198
|
+
Dir.mktmpdir do |dir|
|
199
|
+
pid_file = File.join(dir, 'pid')
|
200
|
+
pid = spawn_with_cleanup(*wrapper, *sigint, pid_file)
|
201
|
+
wait_for_file(pid_file)
|
202
|
+
|
203
|
+
child_pid = Integer(File.read(pid_file).chomp)
|
204
|
+
expect(Process.getpgid(child_pid)).to eq(Process.getpgid(pid))
|
205
|
+
Process.kill('INT', -pid)
|
206
|
+
|
207
|
+
status = wait_for_pid(pid)
|
208
|
+
expect(status.exitstatus).to eq(128 + Signal.list.fetch('INT'))
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
92
213
|
end
|
@@ -165,6 +165,34 @@ describe Aptible::CLI::Agent do
|
|
165
165
|
end
|
166
166
|
end
|
167
167
|
|
168
|
+
describe '#config:set' do
|
169
|
+
before do
|
170
|
+
allow(Aptible::Api::App).to receive(:all) { [app] }
|
171
|
+
allow(Aptible::Api::Account).to receive(:all) { [account] }
|
172
|
+
end
|
173
|
+
|
174
|
+
it 'should reject environment variables that start with -' do
|
175
|
+
allow(subject).to receive(:options) { { app: 'hello' } }
|
176
|
+
|
177
|
+
expect { subject.send('config:set', '-foo=bar') }
|
178
|
+
.to raise_error(/invalid argument/im)
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
describe '#config:rm' do
|
183
|
+
before do
|
184
|
+
allow(Aptible::Api::App).to receive(:all) { [app] }
|
185
|
+
allow(Aptible::Api::Account).to receive(:all) { [account] }
|
186
|
+
end
|
187
|
+
|
188
|
+
it 'should reject environment variables that start with -' do
|
189
|
+
allow(subject).to receive(:options) { { app: 'hello' } }
|
190
|
+
|
191
|
+
expect { subject.send('config:rm', '-foo') }
|
192
|
+
.to raise_error(/invalid argument/im)
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
168
196
|
describe '#ensure_app' do
|
169
197
|
it 'fails if no usable strategy is found' do
|
170
198
|
strategies = [dummy_strategy_factory(nil, nil, false)]
|
@@ -0,0 +1,158 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Aptible::CLI::Agent do
|
4
|
+
let!(:account) { Fabricate(:account, handle: 'foobar') }
|
5
|
+
let!(:app) { Fabricate(:app, handle: 'hello', account: account) }
|
6
|
+
let(:operation) { Fabricate(:operation) }
|
7
|
+
|
8
|
+
describe '#deploy' do
|
9
|
+
before do
|
10
|
+
allow(Aptible::Api::App).to receive(:all) { [app] }
|
11
|
+
allow(Aptible::Api::Account).to receive(:all) { [account] }
|
12
|
+
subject.stub(:fetch_token) { double 'token' }
|
13
|
+
end
|
14
|
+
|
15
|
+
context 'with app' do
|
16
|
+
let(:base_options) { { app: app.handle, environment: account.handle } }
|
17
|
+
|
18
|
+
def stub_options(**opts)
|
19
|
+
allow(subject).to receive(:options).and_return(base_options.merge(opts))
|
20
|
+
end
|
21
|
+
|
22
|
+
it 'deploys' do
|
23
|
+
stub_options
|
24
|
+
|
25
|
+
expect(app).to receive(:create_operation!)
|
26
|
+
.with(type: 'deploy').and_return(operation)
|
27
|
+
expect(subject).to receive(:attach_to_operation_logs)
|
28
|
+
.with(operation)
|
29
|
+
|
30
|
+
subject.deploy
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'deploys a committish' do
|
34
|
+
stub_options(git_commitish: 'foobar')
|
35
|
+
|
36
|
+
expect(app).to receive(:create_operation!)
|
37
|
+
.with(type: 'deploy', git_ref: 'foobar').and_return(operation)
|
38
|
+
expect(subject).to receive(:attach_to_operation_logs)
|
39
|
+
.with(operation)
|
40
|
+
|
41
|
+
subject.deploy
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'deploys a Docker image' do
|
45
|
+
stub_options(docker_image: 'foobar')
|
46
|
+
|
47
|
+
expect(app).to receive(:create_operation!)
|
48
|
+
.with(type: 'deploy', env: { 'APTIBLE_DOCKER_IMAGE' => 'foobar' })
|
49
|
+
.and_return(operation)
|
50
|
+
expect(subject).to receive(:attach_to_operation_logs)
|
51
|
+
.with(operation)
|
52
|
+
|
53
|
+
subject.deploy
|
54
|
+
end
|
55
|
+
|
56
|
+
it 'deploys with credentials' do
|
57
|
+
stub_options(
|
58
|
+
private_registry_email: 'foo',
|
59
|
+
private_registry_username: 'bar',
|
60
|
+
private_registry_password: 'qux'
|
61
|
+
)
|
62
|
+
|
63
|
+
env = {
|
64
|
+
'APTIBLE_PRIVATE_REGISTRY_EMAIL' => 'foo',
|
65
|
+
'APTIBLE_PRIVATE_REGISTRY_USERNAME' => 'bar',
|
66
|
+
'APTIBLE_PRIVATE_REGISTRY_PASSWORD' => 'qux'
|
67
|
+
}
|
68
|
+
|
69
|
+
expect(app).to receive(:create_operation!)
|
70
|
+
.with(type: 'deploy', env: env)
|
71
|
+
.and_return(operation)
|
72
|
+
expect(subject).to receive(:attach_to_operation_logs)
|
73
|
+
.with(operation)
|
74
|
+
|
75
|
+
subject.deploy
|
76
|
+
end
|
77
|
+
|
78
|
+
it 'detaches a git repo' do
|
79
|
+
stub_options(git_detach: true)
|
80
|
+
|
81
|
+
ref = '0000000000000000000000000000000000000000'
|
82
|
+
|
83
|
+
expect(app).to receive(:create_operation!)
|
84
|
+
.with(type: 'deploy', git_ref: ref)
|
85
|
+
.and_return(operation)
|
86
|
+
expect(subject).to receive(:attach_to_operation_logs)
|
87
|
+
.with(operation)
|
88
|
+
|
89
|
+
subject.deploy
|
90
|
+
end
|
91
|
+
|
92
|
+
it 'fails if detaching the git repo and providing a commitish' do
|
93
|
+
stub_options(git_commitish: 'foo', git_detach: true)
|
94
|
+
|
95
|
+
expect { subject.deploy }.to raise_error(/are incompatible/im)
|
96
|
+
end
|
97
|
+
|
98
|
+
it 'allows setting configuration variables' do
|
99
|
+
stub_options
|
100
|
+
|
101
|
+
expect(app).to receive(:create_operation!)
|
102
|
+
.with(type: 'deploy', env: { 'FOO' => 'bar', 'BAR' => 'qux' })
|
103
|
+
.and_return(operation)
|
104
|
+
expect(subject).to receive(:attach_to_operation_logs)
|
105
|
+
.with(operation)
|
106
|
+
|
107
|
+
subject.deploy('FOO=bar', 'BAR=qux')
|
108
|
+
end
|
109
|
+
|
110
|
+
it 'allows unsetting configuration variables' do
|
111
|
+
stub_options
|
112
|
+
|
113
|
+
expect(app).to receive(:create_operation!)
|
114
|
+
.with(type: 'deploy', env: { 'FOO' => '' })
|
115
|
+
.and_return(operation)
|
116
|
+
expect(subject).to receive(:attach_to_operation_logs)
|
117
|
+
.with(operation)
|
118
|
+
|
119
|
+
subject.deploy('FOO=')
|
120
|
+
end
|
121
|
+
|
122
|
+
it 'rejects arguments with a leading -' do
|
123
|
+
stub_options
|
124
|
+
|
125
|
+
expect { subject.deploy('--aptible-docker-image=bar') }
|
126
|
+
.to raise_error(/invalid argument/im)
|
127
|
+
end
|
128
|
+
|
129
|
+
it 'allows redundant command line arguments' do
|
130
|
+
stub_options(docker_image: 'foobar')
|
131
|
+
|
132
|
+
expect(app).to receive(:create_operation!)
|
133
|
+
.with(type: 'deploy', env: { 'APTIBLE_DOCKER_IMAGE' => 'foobar' })
|
134
|
+
.and_return(operation)
|
135
|
+
expect(subject).to receive(:attach_to_operation_logs)
|
136
|
+
.with(operation)
|
137
|
+
|
138
|
+
subject.deploy('APTIBLE_DOCKER_IMAGE=foobar')
|
139
|
+
end
|
140
|
+
|
141
|
+
it 'reject contradictory command line argumnts' do
|
142
|
+
stub_options(docker_image: 'foobar')
|
143
|
+
|
144
|
+
expect { subject.deploy('APTIBLE_DOCKER_IMAGE=qux') }
|
145
|
+
.to raise_error(/different values/im)
|
146
|
+
end
|
147
|
+
|
148
|
+
it 'does not allow deploying nothing on an unprovisioned app' do
|
149
|
+
stub_options
|
150
|
+
|
151
|
+
app.stub(status: 'pending')
|
152
|
+
|
153
|
+
expect { subject.deploy }
|
154
|
+
.to raise_error(/either from git.*docker/im)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
@@ -27,7 +27,7 @@ describe Aptible::CLI::Agent do
|
|
27
27
|
expect(app).to receive(:create_operation!).with(
|
28
28
|
type: 'logs', status: 'succeeded'
|
29
29
|
).and_return(op)
|
30
|
-
expect(subject).to receive(:
|
30
|
+
expect(subject).to receive(:exit_with_ssh_portal).with(op, any_args)
|
31
31
|
subject.send('logs')
|
32
32
|
end
|
33
33
|
end
|
@@ -47,7 +47,7 @@ describe Aptible::CLI::Agent do
|
|
47
47
|
expect(database).to receive(:create_operation!).with(
|
48
48
|
type: 'logs', status: 'succeeded'
|
49
49
|
).and_return(op)
|
50
|
-
expect(subject).to receive(:
|
50
|
+
expect(subject).to receive(:exit_with_ssh_portal).with(op, any_args)
|
51
51
|
subject.send('logs')
|
52
52
|
end
|
53
53
|
end
|
@@ -19,7 +19,7 @@ describe Aptible::CLI::Agent do
|
|
19
19
|
allow(STDIN).to receive(:tty?).and_return(true)
|
20
20
|
allow(STDOUT).to receive(:tty?).and_return(true)
|
21
21
|
|
22
|
-
expect(subject).to receive(:
|
22
|
+
expect(subject).to receive(:exit_with_ssh_portal).with(
|
23
23
|
operation, '-o', 'SendEnv=ACCESS_TOKEN', '-t'
|
24
24
|
)
|
25
25
|
subject.ssh
|
@@ -30,7 +30,7 @@ describe Aptible::CLI::Agent do
|
|
30
30
|
allow(STDOUT).to receive(:tty?).and_return(true)
|
31
31
|
allow(STDERR).to receive(:tty?).and_return(false)
|
32
32
|
|
33
|
-
expect(subject).to receive(:
|
33
|
+
expect(subject).to receive(:exit_with_ssh_portal).with(
|
34
34
|
operation, '-o', 'SendEnv=ACCESS_TOKEN', '-t'
|
35
35
|
)
|
36
36
|
subject.ssh
|
@@ -40,7 +40,7 @@ describe Aptible::CLI::Agent do
|
|
40
40
|
allow(STDIN).to receive(:tty?).and_return(false)
|
41
41
|
allow(STDOUT).to receive(:tty?).and_return(true)
|
42
42
|
|
43
|
-
expect(subject).to receive(:
|
43
|
+
expect(subject).to receive(:exit_with_ssh_portal).with(
|
44
44
|
operation, '-o', 'SendEnv=ACCESS_TOKEN', '-T'
|
45
45
|
)
|
46
46
|
subject.ssh
|
@@ -50,7 +50,7 @@ describe Aptible::CLI::Agent do
|
|
50
50
|
allow(STDIN).to receive(:tty?).and_return(true)
|
51
51
|
allow(STDOUT).to receive(:tty?).and_return(false)
|
52
52
|
|
53
|
-
expect(subject).to receive(:
|
53
|
+
expect(subject).to receive(:exit_with_ssh_portal).with(
|
54
54
|
operation, '-o', 'SendEnv=ACCESS_TOKEN', '-T'
|
55
55
|
)
|
56
56
|
subject.ssh
|
@@ -62,7 +62,7 @@ describe Aptible::CLI::Agent do
|
|
62
62
|
allow(STDIN).to receive(:tty?).and_return(false)
|
63
63
|
allow(STDOUT).to receive(:tty?).and_return(false)
|
64
64
|
|
65
|
-
expect(subject).to receive(:
|
65
|
+
expect(subject).to receive(:exit_with_ssh_portal).with(
|
66
66
|
operation, '-o', 'SendEnv=ACCESS_TOKEN', '-tt'
|
67
67
|
)
|
68
68
|
subject.ssh
|
data/spec/script/setpgid
ADDED
metadata
CHANGED
@@ -1,57 +1,57 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: aptible-cli
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.11.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Frank Macreery
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2017-
|
11
|
+
date: 2017-06-28 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name: aptible-
|
14
|
+
name: aptible-resource
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
17
|
- - "~>"
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: 0.
|
19
|
+
version: 0.4.0
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
24
|
- - "~>"
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version: 0.
|
26
|
+
version: 0.4.0
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
|
-
name: aptible-
|
28
|
+
name: aptible-api
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
30
30
|
requirements:
|
31
31
|
- - "~>"
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version: 0.
|
33
|
+
version: 0.9.27
|
34
34
|
type: :runtime
|
35
35
|
prerelease: false
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
37
37
|
requirements:
|
38
38
|
- - "~>"
|
39
39
|
- !ruby/object:Gem::Version
|
40
|
-
version: 0.
|
40
|
+
version: 0.9.27
|
41
41
|
- !ruby/object:Gem::Dependency
|
42
|
-
name: aptible-
|
42
|
+
name: aptible-auth
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
44
44
|
requirements:
|
45
45
|
- - "~>"
|
46
46
|
- !ruby/object:Gem::Version
|
47
|
-
version: 0.
|
47
|
+
version: 0.11.13
|
48
48
|
type: :runtime
|
49
49
|
prerelease: false
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
51
51
|
requirements:
|
52
52
|
- - "~>"
|
53
53
|
- !ruby/object:Gem::Version
|
54
|
-
version: 0.
|
54
|
+
version: 0.11.13
|
55
55
|
- !ruby/object:Gem::Dependency
|
56
56
|
name: thor
|
57
57
|
requirement: !ruby/object:Gem::Requirement
|
@@ -238,6 +238,7 @@ files:
|
|
238
238
|
- lib/aptible/cli/subcommands/backup.rb
|
239
239
|
- lib/aptible/cli/subcommands/config.rb
|
240
240
|
- lib/aptible/cli/subcommands/db.rb
|
241
|
+
- lib/aptible/cli/subcommands/deploy.rb
|
241
242
|
- lib/aptible/cli/subcommands/domains.rb
|
242
243
|
- lib/aptible/cli/subcommands/inspect.rb
|
243
244
|
- lib/aptible/cli/subcommands/logs.rb
|
@@ -247,6 +248,7 @@ files:
|
|
247
248
|
- lib/aptible/cli/subcommands/restart.rb
|
248
249
|
- lib/aptible/cli/subcommands/ssh.rb
|
249
250
|
- lib/aptible/cli/version.rb
|
251
|
+
- script/sync-readme-usage
|
250
252
|
- spec/aptible/cli/agent_spec.rb
|
251
253
|
- spec/aptible/cli/helpers/git_remote_handle_strategy_spec.rb
|
252
254
|
- spec/aptible/cli/helpers/handle_from_git_remote_spec.rb
|
@@ -257,6 +259,7 @@ files:
|
|
257
259
|
- spec/aptible/cli/subcommands/apps_spec.rb
|
258
260
|
- spec/aptible/cli/subcommands/backup_spec.rb
|
259
261
|
- spec/aptible/cli/subcommands/db_spec.rb
|
262
|
+
- spec/aptible/cli/subcommands/deploy_spec.rb
|
260
263
|
- spec/aptible/cli/subcommands/domains_spec.rb
|
261
264
|
- spec/aptible/cli/subcommands/inspect_spec.rb
|
262
265
|
- spec/aptible/cli/subcommands/logs_spec.rb
|
@@ -276,6 +279,10 @@ files:
|
|
276
279
|
- spec/mock/ssh
|
277
280
|
- spec/mock/ssh.bat
|
278
281
|
- spec/mock/ssh_mock.rb
|
282
|
+
- spec/script/exit-with
|
283
|
+
- spec/script/pid-signal
|
284
|
+
- spec/script/setpgid
|
285
|
+
- spec/script/ssh-spawn
|
279
286
|
- spec/shared/mock_ssh_context.rb
|
280
287
|
- spec/spec_helper.rb
|
281
288
|
homepage: https://github.com/aptible/aptible-cli
|
@@ -313,6 +320,7 @@ test_files:
|
|
313
320
|
- spec/aptible/cli/subcommands/apps_spec.rb
|
314
321
|
- spec/aptible/cli/subcommands/backup_spec.rb
|
315
322
|
- spec/aptible/cli/subcommands/db_spec.rb
|
323
|
+
- spec/aptible/cli/subcommands/deploy_spec.rb
|
316
324
|
- spec/aptible/cli/subcommands/domains_spec.rb
|
317
325
|
- spec/aptible/cli/subcommands/inspect_spec.rb
|
318
326
|
- spec/aptible/cli/subcommands/logs_spec.rb
|
@@ -332,5 +340,9 @@ test_files:
|
|
332
340
|
- spec/mock/ssh
|
333
341
|
- spec/mock/ssh.bat
|
334
342
|
- spec/mock/ssh_mock.rb
|
343
|
+
- spec/script/exit-with
|
344
|
+
- spec/script/pid-signal
|
345
|
+
- spec/script/setpgid
|
346
|
+
- spec/script/ssh-spawn
|
335
347
|
- spec/shared/mock_ssh_context.rb
|
336
348
|
- spec/spec_helper.rb
|