minestrone 0.0.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.
- checksums.yaml +7 -0
- data/.github/workflows/main.yml +32 -0
- data/.gitignore +5 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +22 -0
- data/README.md +35 -0
- data/Rakefile +10 -0
- data/bin/capify +89 -0
- data/bin/min +5 -0
- data/docs/lib-codebase-map.md +162 -0
- data/docs/lib-dependency-graph.svg +129 -0
- data/lib/minestrone/callback.rb +45 -0
- data/lib/minestrone/cli/help.rb +131 -0
- data/lib/minestrone/cli/help.txt +72 -0
- data/lib/minestrone/cli/options.rb +232 -0
- data/lib/minestrone/cli.rb +159 -0
- data/lib/minestrone/command.rb +177 -0
- data/lib/minestrone/configuration/actions/file_transfer.rb +53 -0
- data/lib/minestrone/configuration/actions/inspect.rb +46 -0
- data/lib/minestrone/configuration/actions/invocation.rb +202 -0
- data/lib/minestrone/configuration/alias_task.rb +29 -0
- data/lib/minestrone/configuration/callbacks.rb +129 -0
- data/lib/minestrone/configuration/connections.rb +66 -0
- data/lib/minestrone/configuration/execution.rb +139 -0
- data/lib/minestrone/configuration/loading.rb +207 -0
- data/lib/minestrone/configuration/log_formatters.rb +75 -0
- data/lib/minestrone/configuration/namespaces.rb +225 -0
- data/lib/minestrone/configuration/servers.rb +70 -0
- data/lib/minestrone/configuration/variables.rb +115 -0
- data/lib/minestrone/configuration.rb +69 -0
- data/lib/minestrone/errors.rb +17 -0
- data/lib/minestrone/ext/string.rb +7 -0
- data/lib/minestrone/extensions.rb +56 -0
- data/lib/minestrone/logger.rb +171 -0
- data/lib/minestrone/processable.rb +50 -0
- data/lib/minestrone/recipes/deploy/assets.rb +194 -0
- data/lib/minestrone/recipes/deploy/bundler.rb +81 -0
- data/lib/minestrone/recipes/deploy/dependencies.rb +44 -0
- data/lib/minestrone/recipes/deploy/local_dependency.rb +45 -0
- data/lib/minestrone/recipes/deploy/remote_dependency.rb +119 -0
- data/lib/minestrone/recipes/deploy/scm/base.rb +204 -0
- data/lib/minestrone/recipes/deploy/scm/git.rb +284 -0
- data/lib/minestrone/recipes/deploy/scm/none.rb +54 -0
- data/lib/minestrone/recipes/deploy/scm.rb +22 -0
- data/lib/minestrone/recipes/deploy/strategy/base.rb +87 -0
- data/lib/minestrone/recipes/deploy/strategy/copy.rb +353 -0
- data/lib/minestrone/recipes/deploy/strategy/remote_cache.rb +80 -0
- data/lib/minestrone/recipes/deploy/strategy.rb +22 -0
- data/lib/minestrone/recipes/deploy.rb +639 -0
- data/lib/minestrone/recipes/standard.rb +23 -0
- data/lib/minestrone/recipes/templates/maintenance.rhtml +53 -0
- data/lib/minestrone/server_definition.rb +56 -0
- data/lib/minestrone/ssh.rb +81 -0
- data/lib/minestrone/task_definition.rb +82 -0
- data/lib/minestrone/transfer.rb +205 -0
- data/lib/minestrone/version.rb +11 -0
- data/lib/minestrone.rb +3 -0
- data/minestrone.gemspec +32 -0
- data/test/cli/execute_test.rb +130 -0
- data/test/cli/help_test.rb +178 -0
- data/test/cli/options_test.rb +315 -0
- data/test/cli/ui_test.rb +26 -0
- data/test/cli_test.rb +17 -0
- data/test/command_test.rb +305 -0
- data/test/configuration/actions/file_transfer_test.rb +61 -0
- data/test/configuration/actions/inspect_test.rb +76 -0
- data/test/configuration/actions/invocation_test.rb +258 -0
- data/test/configuration/alias_task_test.rb +110 -0
- data/test/configuration/callbacks_test.rb +201 -0
- data/test/configuration/connections_test.rb +192 -0
- data/test/configuration/execution_test.rb +176 -0
- data/test/configuration/loading_test.rb +149 -0
- data/test/configuration/namespace_dsl_test.rb +325 -0
- data/test/configuration/servers_test.rb +100 -0
- data/test/configuration/variables_test.rb +191 -0
- data/test/configuration_test.rb +77 -0
- data/test/deploy/local_dependency_test.rb +61 -0
- data/test/deploy/remote_dependency_test.rb +146 -0
- data/test/deploy/scm/base_test.rb +55 -0
- data/test/deploy/scm/git_test.rb +260 -0
- data/test/deploy/scm/none_test.rb +26 -0
- data/test/deploy/strategy/copy_test.rb +360 -0
- data/test/extensions_test.rb +69 -0
- data/test/fixtures/cli_integration.rb +5 -0
- data/test/fixtures/config.rb +4 -0
- data/test/fixtures/custom.rb +3 -0
- data/test/logger_formatting_test.rb +149 -0
- data/test/logger_test.rb +134 -0
- data/test/recipes_test.rb +26 -0
- data/test/server_definition_test.rb +121 -0
- data/test/ssh_test.rb +99 -0
- data/test/task_definition_test.rb +117 -0
- data/test/transfer_test.rb +172 -0
- data/test/utils.rb +28 -0
- data/test/version_test.rb +11 -0
- metadata +258 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
|
|
2
|
+
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
|
3
|
+
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
4
|
+
|
|
5
|
+
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
|
6
|
+
|
|
7
|
+
<head>
|
|
8
|
+
<meta http-equiv="content-type" content="text/html;charset=UTF-8" />
|
|
9
|
+
<title>System down for maintenance</title>
|
|
10
|
+
|
|
11
|
+
<style type="text/css">
|
|
12
|
+
div.outer {
|
|
13
|
+
position: absolute;
|
|
14
|
+
left: 50%;
|
|
15
|
+
top: 50%;
|
|
16
|
+
width: 500px;
|
|
17
|
+
height: 300px;
|
|
18
|
+
margin-left: -260px;
|
|
19
|
+
margin-top: -150px;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.DialogBody {
|
|
23
|
+
margin: 0;
|
|
24
|
+
padding: 10px;
|
|
25
|
+
text-align: left;
|
|
26
|
+
border: 1px solid #ccc;
|
|
27
|
+
border-right: 1px solid #999;
|
|
28
|
+
border-bottom: 1px solid #999;
|
|
29
|
+
background-color: #fff;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
body { background-color: #fff; }
|
|
33
|
+
</style>
|
|
34
|
+
</head>
|
|
35
|
+
|
|
36
|
+
<body>
|
|
37
|
+
|
|
38
|
+
<div class="outer">
|
|
39
|
+
<div class="DialogBody" style="text-align: center;">
|
|
40
|
+
<div style="text-align: center; width: 200px; margin: 0 auto;">
|
|
41
|
+
<p style="color: red; font-size: 16px; line-height: 20px;">
|
|
42
|
+
The system is down for <%= reason ? reason : "maintenance" %>
|
|
43
|
+
as of <%= Time.now.strftime("%H:%M %Z") %>.
|
|
44
|
+
</p>
|
|
45
|
+
<p style="color: #666;">
|
|
46
|
+
It'll be back <%= deadline ? deadline : "shortly" %>.
|
|
47
|
+
</p>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
</body>
|
|
53
|
+
</html>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Minestrone
|
|
4
|
+
class ServerDefinition
|
|
5
|
+
include Comparable
|
|
6
|
+
|
|
7
|
+
attr_reader :host
|
|
8
|
+
attr_reader :user
|
|
9
|
+
attr_reader :port
|
|
10
|
+
attr_reader :options
|
|
11
|
+
|
|
12
|
+
# The default user name to use when a user name is not explicitly provided
|
|
13
|
+
def self.default_user
|
|
14
|
+
ENV['USER'] || ENV['USERNAME'] || "not-specified"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(string, options = {})
|
|
18
|
+
@user, @host, @port = string.match(/^(?:([^;,:=]+)@|)(.*?)(?::(\d+)|)$/)[1,3]
|
|
19
|
+
|
|
20
|
+
@options = options.dup
|
|
21
|
+
user_opt, port_opt = @options.delete(:user), @options.delete(:port)
|
|
22
|
+
|
|
23
|
+
@user ||= user_opt
|
|
24
|
+
@port ||= port_opt
|
|
25
|
+
|
|
26
|
+
@port = @port.to_i if @port
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def <=>(server)
|
|
30
|
+
[host, port, user] <=> [server.host, server.port, server.user]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Redefined, so that Array#uniq will work to remove duplicate server
|
|
34
|
+
# definitions, based solely on their host names.
|
|
35
|
+
def eql?(server)
|
|
36
|
+
(host == server.host) && (user == server.user) && (port == server.port)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
alias :== :eql?
|
|
40
|
+
|
|
41
|
+
# Redefined, so that Array#uniq will work to remove duplicate server
|
|
42
|
+
# definitions, based on their connection information.
|
|
43
|
+
def hash
|
|
44
|
+
@hash ||= [host, user, port].hash
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_s
|
|
48
|
+
@to_s ||= begin
|
|
49
|
+
s = host
|
|
50
|
+
s = "#{user}@#{s}" if user
|
|
51
|
+
s = "#{s}:#{port}" if port && port != 22
|
|
52
|
+
s
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
require 'net/ssh'
|
|
2
|
+
|
|
3
|
+
module Minestrone
|
|
4
|
+
|
|
5
|
+
#
|
|
6
|
+
# A helper class for dealing with SSH connections.
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
class SSH
|
|
10
|
+
|
|
11
|
+
# Patch an accessor onto an SSH connection so that we can record the server
|
|
12
|
+
# definition object that defines the connection.
|
|
13
|
+
module Server #:nodoc:
|
|
14
|
+
def self.apply_to(connection, server)
|
|
15
|
+
connection.extend(Server)
|
|
16
|
+
connection.xserver = server
|
|
17
|
+
connection
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
attr_accessor :xserver
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# An abstraction to make it possible to connect to the server via public key.
|
|
24
|
+
#
|
|
25
|
+
# +server+ must be an instance of ServerDefinition.
|
|
26
|
+
#
|
|
27
|
+
# If a block is given, the new session is yielded to it, otherwise the new
|
|
28
|
+
# session is returned.
|
|
29
|
+
#
|
|
30
|
+
# If an :ssh_options key exists in +options+, it is passed to the Net::SSH
|
|
31
|
+
# constructor. Values in +options+ are then merged into it, and any
|
|
32
|
+
# connection information in +server+ is added last, so that +server+ info
|
|
33
|
+
# takes precedence over +options+, which takes precendence over ssh_options.
|
|
34
|
+
|
|
35
|
+
def self.connect(server, options = {})
|
|
36
|
+
connection_strategy(server, options) do |host, user, connection_options|
|
|
37
|
+
connection = Net::SSH.start(host, user, connection_options)
|
|
38
|
+
Server.apply_to(connection, server)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Abstracts the logic for establishing an SSH connection.
|
|
43
|
+
#
|
|
44
|
+
# This will yield the hostname, username, and a hash of connection options
|
|
45
|
+
# to the given block, which should return a new connection.
|
|
46
|
+
|
|
47
|
+
def self.connection_strategy(server, options = {}, &block)
|
|
48
|
+
|
|
49
|
+
# construct the hash of ssh options that should be passed more-or-less
|
|
50
|
+
# directly to Net::SSH. This will be the general ssh options, merged with
|
|
51
|
+
# the server-specific ssh-options.
|
|
52
|
+
ssh_options = (options[:ssh_options] || {}).merge(server.options[:ssh_options] || {})
|
|
53
|
+
|
|
54
|
+
# load any SSH configuration files that were specified in the SSH options. This
|
|
55
|
+
# will load from ~/.ssh/config and /etc/ssh_config by default (see Net::SSH
|
|
56
|
+
# for details). Merge the explicitly given ssh_options over the top of the info
|
|
57
|
+
# from the config file.
|
|
58
|
+
ssh_options = Net::SSH.configuration_for(server.host, ssh_options.fetch(:config, true)).merge(ssh_options)
|
|
59
|
+
|
|
60
|
+
# Once we've loaded the config, we don't need Net::SSH to do it again.
|
|
61
|
+
ssh_options[:config] = false
|
|
62
|
+
|
|
63
|
+
ssh_options[:verbose] = :debug if options[:verbose] && options[:verbose] > 0
|
|
64
|
+
|
|
65
|
+
user = server.user || options[:user] || ssh_options[:username] || ssh_options[:user] || ServerDefinition.default_user
|
|
66
|
+
port = server.port || options[:port] || ssh_options[:port]
|
|
67
|
+
|
|
68
|
+
# the .ssh/config file might have changed the host-name on us
|
|
69
|
+
host = ssh_options.fetch(:host_name, server.host)
|
|
70
|
+
|
|
71
|
+
ssh_options[:port] = port if port
|
|
72
|
+
|
|
73
|
+
# delete these, since we've determined which username to use by this point
|
|
74
|
+
ssh_options.delete(:username)
|
|
75
|
+
ssh_options.delete(:user)
|
|
76
|
+
|
|
77
|
+
connection_options = ssh_options.merge(:auth_methods => ['publickey'])
|
|
78
|
+
yield host, user, connection_options
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'minestrone/server_definition'
|
|
4
|
+
|
|
5
|
+
module Minestrone
|
|
6
|
+
|
|
7
|
+
class TaskDefinition
|
|
8
|
+
attr_reader :name, :namespace, :options, :body, :desc, :on_error
|
|
9
|
+
|
|
10
|
+
def initialize(name, namespace, options = {}, &block)
|
|
11
|
+
@name, @namespace, @options = name, namespace, options
|
|
12
|
+
@desc = @options.delete(:desc)
|
|
13
|
+
@on_error = options.delete(:on_error)
|
|
14
|
+
@body = block or raise ArgumentError, "a task requires a block"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns the task's fully-qualified name, including the namespace
|
|
18
|
+
def fully_qualified_name
|
|
19
|
+
@fully_qualified_name ||= begin
|
|
20
|
+
if namespace.default_task == self
|
|
21
|
+
namespace.fully_qualified_name
|
|
22
|
+
else
|
|
23
|
+
[namespace.fully_qualified_name, name].compact.join(":")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def name=(value)
|
|
29
|
+
raise ArgumentError, "expected a valid task name" if !value.respond_to?(:to_sym)
|
|
30
|
+
@name = value.to_sym
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns the description for this task, with newlines collapsed and
|
|
34
|
+
# whitespace stripped. Returns the empty string if there is no
|
|
35
|
+
# description for this task.
|
|
36
|
+
|
|
37
|
+
def description(rebuild = false)
|
|
38
|
+
@description = nil if rebuild
|
|
39
|
+
|
|
40
|
+
@description ||= begin
|
|
41
|
+
description = @desc || ""
|
|
42
|
+
|
|
43
|
+
indentation = description[/\A\s+/]
|
|
44
|
+
|
|
45
|
+
if indentation
|
|
46
|
+
reformatted_description = "".dup
|
|
47
|
+
|
|
48
|
+
description.strip.each_line do |line|
|
|
49
|
+
line = line.chomp.sub(/^#{indentation}/, "")
|
|
50
|
+
line = line.gsub(/#{indentation}\s*/, " ") if line[/^\S/]
|
|
51
|
+
reformatted_description << line << "\n"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
description = reformatted_description
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
description.strip.gsub(/\r\n/, "\n")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns the first sentence of the full description. If +max_length+ is
|
|
62
|
+
# given, the result will be truncated if it is longer than +max_length+,
|
|
63
|
+
# and an ellipsis appended.
|
|
64
|
+
|
|
65
|
+
def brief_description(max_length = nil)
|
|
66
|
+
brief = description[/^.*?\.(?=\s|$)/] || description
|
|
67
|
+
|
|
68
|
+
if max_length && brief.length > max_length
|
|
69
|
+
brief = brief[0,max_length-3] + "..."
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
brief
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Indicates whether the task wants to continue, even if a server has failed
|
|
76
|
+
# previously
|
|
77
|
+
|
|
78
|
+
def continue_on_error?
|
|
79
|
+
@on_error == :continue
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/scp'
|
|
4
|
+
require 'net/sftp'
|
|
5
|
+
|
|
6
|
+
require 'minestrone/errors'
|
|
7
|
+
require 'minestrone/processable'
|
|
8
|
+
|
|
9
|
+
module Minestrone
|
|
10
|
+
class Transfer
|
|
11
|
+
include Processable
|
|
12
|
+
|
|
13
|
+
def self.process(direction, from, to, session, options = {}, &block)
|
|
14
|
+
new(direction, from, to, session, options, &block).process!
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :session, :options, :callback
|
|
18
|
+
attr_reader :transport, :direction, :from, :to
|
|
19
|
+
attr_reader :logger, :transfer
|
|
20
|
+
|
|
21
|
+
def initialize(direction, from, to, session, options = {}, &block)
|
|
22
|
+
@direction = direction
|
|
23
|
+
@from = from
|
|
24
|
+
@to = to
|
|
25
|
+
@session = session
|
|
26
|
+
@options = options
|
|
27
|
+
@callback = block
|
|
28
|
+
|
|
29
|
+
@transport = options.fetch(:via, :sftp)
|
|
30
|
+
@logger = options.delete(:logger)
|
|
31
|
+
|
|
32
|
+
prepare_transfer
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def process!
|
|
36
|
+
loop do
|
|
37
|
+
begin
|
|
38
|
+
break unless process_iteration { active? }
|
|
39
|
+
rescue Exception => error
|
|
40
|
+
if error.respond_to?(:session)
|
|
41
|
+
handle_error(error)
|
|
42
|
+
else
|
|
43
|
+
raise
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if transfer[:failed]
|
|
49
|
+
server = transfer[:server]
|
|
50
|
+
transfer_error = transfer[:error]
|
|
51
|
+
error = TransferError.new("#{operation} via #{transport} failed on #{server}: #{transfer_error} (#{transfer_error.message})")
|
|
52
|
+
error.host = server
|
|
53
|
+
|
|
54
|
+
logger.important(error.message) if logger
|
|
55
|
+
raise error
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
logger.debug "#{transport} #{operation} complete" if logger
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def active?
|
|
63
|
+
transfer.active?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def operation
|
|
67
|
+
"#{direction}load"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def sanitized_from
|
|
71
|
+
if from.responds_to?(:read)
|
|
72
|
+
"#<#{from.class}>"
|
|
73
|
+
else
|
|
74
|
+
from
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def sanitized_to
|
|
79
|
+
if to.responds_to?(:read)
|
|
80
|
+
"#<#{to.class}>"
|
|
81
|
+
else
|
|
82
|
+
to
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def prepare_transfer
|
|
90
|
+
logger.info "#{transport} #{operation} #{from} -> #{to}" if logger
|
|
91
|
+
|
|
92
|
+
session_from = normalize(from)
|
|
93
|
+
session_to = normalize(to)
|
|
94
|
+
|
|
95
|
+
@transfer = case transport
|
|
96
|
+
when :sftp
|
|
97
|
+
prepare_sftp_transfer(session_from, session_to)
|
|
98
|
+
when :scp
|
|
99
|
+
prepare_scp_transfer(session_from, session_to)
|
|
100
|
+
else
|
|
101
|
+
raise ArgumentError, "unsupported transport type: #{transport.inspect}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def prepare_scp_transfer(from, to)
|
|
106
|
+
real_callback = callback || Proc.new do |channel, name, sent, total|
|
|
107
|
+
logger.trace "[#{channel[:host]}] #{name}" if logger && sent == 0
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
channel = case direction
|
|
111
|
+
when :up
|
|
112
|
+
session.scp.upload(from, to, options, &real_callback)
|
|
113
|
+
when :down
|
|
114
|
+
session.scp.download(from, to, options, &real_callback)
|
|
115
|
+
else
|
|
116
|
+
raise ArgumentError, "unsupported transfer direction: #{direction.inspect}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
channel[:server] = session.xserver
|
|
120
|
+
channel[:host] = session.xserver.host
|
|
121
|
+
|
|
122
|
+
channel
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
class SFTPTransferWrapper
|
|
126
|
+
attr_reader :operation
|
|
127
|
+
|
|
128
|
+
def initialize(session, &callback)
|
|
129
|
+
session.sftp(false).connect do |sftp|
|
|
130
|
+
@operation = callback.call(sftp)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def active?
|
|
135
|
+
@operation.nil? || @operation.active?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def [](key)
|
|
139
|
+
@operation[key]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def []=(key, value)
|
|
143
|
+
@operation[key] = value
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def abort!
|
|
147
|
+
@operation.abort!
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def prepare_sftp_transfer(from, to)
|
|
152
|
+
SFTPTransferWrapper.new(session) do |sftp|
|
|
153
|
+
real_callback = Proc.new do |event, op, *args|
|
|
154
|
+
if callback
|
|
155
|
+
callback.call(event, op, *args)
|
|
156
|
+
elsif event == :open
|
|
157
|
+
logger.trace "[#{op[:host]}] #{args[0].remote}"
|
|
158
|
+
elsif event == :finish
|
|
159
|
+
logger.trace "[#{op[:host]}] done"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
opts = options.dup
|
|
164
|
+
opts[:properties] = (opts[:properties] || {}).merge(
|
|
165
|
+
:server => session.xserver,
|
|
166
|
+
:host => session.xserver.host
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
case direction
|
|
170
|
+
when :up
|
|
171
|
+
sftp.upload(from, to, opts, &real_callback)
|
|
172
|
+
when :down
|
|
173
|
+
sftp.download(from, to, opts, &real_callback)
|
|
174
|
+
else
|
|
175
|
+
raise ArgumentError, "unsupported transfer direction: #{direction.inspect}"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def normalize(argument)
|
|
181
|
+
if argument.is_a?(String)
|
|
182
|
+
argument.gsub(/\$CAPISTRANO:HOST\$/, session.xserver.host)
|
|
183
|
+
elsif argument.respond_to?(:read)
|
|
184
|
+
pos = argument.pos
|
|
185
|
+
clone = StringIO.new(argument.read)
|
|
186
|
+
clone.pos = argument.pos = pos
|
|
187
|
+
clone
|
|
188
|
+
else
|
|
189
|
+
argument
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def handle_error(error)
|
|
194
|
+
raise error if error.message.include?('expected a file to upload')
|
|
195
|
+
|
|
196
|
+
transfer[:error] = error
|
|
197
|
+
transfer[:failed] = true
|
|
198
|
+
|
|
199
|
+
case transport
|
|
200
|
+
when :sftp then transfer.abort!
|
|
201
|
+
when :scp then transfer.close
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
data/lib/minestrone.rb
ADDED
data/minestrone.gemspec
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
$LOAD_PATH.push File.expand_path("lib", __dir__)
|
|
4
|
+
require "minestrone/version"
|
|
5
|
+
|
|
6
|
+
Gem::Specification.new do |spec|
|
|
7
|
+
spec.name = "minestrone"
|
|
8
|
+
spec.version = Minestrone::Version.to_s
|
|
9
|
+
spec.platform = Gem::Platform::RUBY
|
|
10
|
+
spec.authors = ["Jamis Buck", "Lee Hambley", "Kuba Suder"]
|
|
11
|
+
spec.homepage = "http://github.com/mackuba/minestrone"
|
|
12
|
+
spec.summary = "Minestrone - Welcome to easy deployment with Ruby over SSH"
|
|
13
|
+
spec.description = "Minestrone is a utility and framework for executing commands on a remote machine, via SSH."
|
|
14
|
+
spec.files = `git ls-files`.split("\n")
|
|
15
|
+
spec.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
|
16
|
+
spec.executables = `git ls-files -- bin/*`.split("\n").map { |file| File.basename(file) }
|
|
17
|
+
spec.require_paths = ["lib"]
|
|
18
|
+
spec.extra_rdoc_files = [
|
|
19
|
+
"README.md"
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
spec.required_ruby_version = ">= 3.0.0"
|
|
23
|
+
|
|
24
|
+
spec.add_dependency 'benchmark', '~> 0.5'
|
|
25
|
+
spec.add_dependency 'highline', '>= 0'
|
|
26
|
+
spec.add_dependency 'net-ssh', '>= 7.2'
|
|
27
|
+
spec.add_dependency 'net-sftp', '>= 3.0'
|
|
28
|
+
spec.add_dependency 'net-scp', '>= 3.0'
|
|
29
|
+
|
|
30
|
+
# used silently by net-ssh but undeclared
|
|
31
|
+
spec.add_dependency 'logger'
|
|
32
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
require "utils"
|
|
2
|
+
require 'minestrone/cli'
|
|
3
|
+
|
|
4
|
+
class CLIExecuteTest < Test::Unit::TestCase
|
|
5
|
+
class MockCLI < Minestrone::CLI
|
|
6
|
+
attr_reader :options
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@options = {}
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def setup
|
|
14
|
+
@cli = MockCLI.new
|
|
15
|
+
@logger = stub_everything
|
|
16
|
+
@config = stub(:logger => @logger, :debug= => nil, :dry_run= => nil)
|
|
17
|
+
@config.stubs(:set)
|
|
18
|
+
@config.stubs(:load)
|
|
19
|
+
@config.stubs(:trigger)
|
|
20
|
+
@cli.stubs(:instantiate_configuration).returns(@config)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def test_execute_should_set_logger_verbosity
|
|
24
|
+
@cli.options[:verbose] = 7
|
|
25
|
+
@logger.expects(:level=).with(7)
|
|
26
|
+
@cli.execute!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def test_execute_should_set_password
|
|
30
|
+
@cli.options[:password] = "nosoup4u"
|
|
31
|
+
@config.expects(:set).with(:password, "nosoup4u")
|
|
32
|
+
@cli.execute!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_execute_should_set_prevars_before_loading
|
|
36
|
+
@config.expects(:load).never
|
|
37
|
+
@config.expects(:set).with(:environment, "foobar")
|
|
38
|
+
@config.expects(:load).with("standard")
|
|
39
|
+
@cli.options[:pre_vars] = { :environment => "foobar" }
|
|
40
|
+
@cli.execute!
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def test_execute_should_load_sysconf_if_sysconf_set_and_exists
|
|
44
|
+
@cli.options[:sysconf] = "/etc/minestrone.conf"
|
|
45
|
+
@config.expects(:load).with("/etc/minestrone.conf")
|
|
46
|
+
File.expects(:file?).with("/etc/minestrone.conf").returns(true)
|
|
47
|
+
@cli.execute!
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def test_execute_should_not_load_sysconf_when_sysconf_set_and_not_exists
|
|
51
|
+
@cli.options[:sysconf] = "/etc/minestrone.conf"
|
|
52
|
+
File.expects(:file?).with("/etc/minestrone.conf").returns(false)
|
|
53
|
+
@cli.execute!
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def test_execute_should_load_dotfile_if_dotfile_set_and_exists
|
|
57
|
+
@cli.options[:dotfile] = "/home/jamis/.caprc"
|
|
58
|
+
@config.expects(:load).with("/home/jamis/.caprc")
|
|
59
|
+
File.expects(:file?).with("/home/jamis/.caprc").returns(true)
|
|
60
|
+
@cli.execute!
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def test_execute_should_not_load_dotfile_when_dotfile_set_and_not_exists
|
|
64
|
+
@cli.options[:dotfile] = "/home/jamis/.caprc"
|
|
65
|
+
File.expects(:file?).with("/home/jamis/.caprc").returns(false)
|
|
66
|
+
@cli.execute!
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def test_execute_should_load_recipes_when_recipes_are_given
|
|
70
|
+
@cli.options[:recipes] = %w(config/deploy path/to/extra)
|
|
71
|
+
@config.expects(:load).with("config/deploy")
|
|
72
|
+
@config.expects(:load).with("path/to/extra")
|
|
73
|
+
@cli.execute!
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def test_execute_should_set_vars_and_execute_tasks
|
|
77
|
+
@cli.options[:vars] = { :foo => "bar", :baz => "bang" }
|
|
78
|
+
@cli.options[:actions] = %w(first second)
|
|
79
|
+
@config.expects(:set).with(:foo, "bar")
|
|
80
|
+
@config.expects(:set).with(:baz, "bang")
|
|
81
|
+
@config.expects(:find_and_execute_task).with("first", :before => :start, :after => :finish)
|
|
82
|
+
@config.expects(:find_and_execute_task).with("second", :before => :start, :after => :finish)
|
|
83
|
+
@cli.execute!
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def test_execute_should_call_load_and_exit_triggers
|
|
87
|
+
@cli.options[:actions] = %w(first second)
|
|
88
|
+
@config.expects(:find_and_execute_task).with("first", :before => :start, :after => :finish)
|
|
89
|
+
@config.expects(:find_and_execute_task).with("second", :before => :start, :after => :finish)
|
|
90
|
+
@config.expects(:trigger).never
|
|
91
|
+
@config.expects(:trigger).with(:load)
|
|
92
|
+
@config.expects(:trigger).with(:exit)
|
|
93
|
+
@cli.execute!
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def test_execute_should_call_handle_error_when_exceptions_occur
|
|
97
|
+
@config.expects(:load).raises(Exception, "boom")
|
|
98
|
+
@cli.expects(:handle_error).with { |e,| Exception === e }
|
|
99
|
+
@cli.execute!
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def test_execute_should_return_config_instance
|
|
103
|
+
assert_equal @config, @cli.execute!
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def test_instantiate_configuration_should_return_new_configuration_instance
|
|
107
|
+
assert_instance_of Minestrone::Configuration, MockCLI.new.instantiate_configuration
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def test_handle_error_with_auth_error_should_abort_with_message_including_user_name
|
|
111
|
+
@cli.expects(:abort).with { |s| s.include?("jamis") }
|
|
112
|
+
@cli.handle_error(Net::SSH::AuthenticationFailed.new("jamis"))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def test_handle_error_with_cap_error_should_abort_with_message
|
|
116
|
+
@cli.expects(:abort).with("Wish you were here")
|
|
117
|
+
@cli.handle_error(Minestrone::Error.new("Wish you were here"))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def test_handle_error_with_other_errors_should_reraise_error
|
|
121
|
+
other_error = Class.new(RuntimeError)
|
|
122
|
+
assert_raises(other_error) { @cli.handle_error(other_error.new("boom")) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def test_class_execute_method_should_call_parse_and_execute_with_ARGV
|
|
126
|
+
cli = mock(:execute! => nil)
|
|
127
|
+
MockCLI.expects(:parse).with(ARGV).returns(cli)
|
|
128
|
+
MockCLI.execute
|
|
129
|
+
end
|
|
130
|
+
end
|