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.
Files changed (96) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +32 -0
  3. data/.gitignore +5 -0
  4. data/Gemfile +10 -0
  5. data/LICENSE.txt +22 -0
  6. data/README.md +35 -0
  7. data/Rakefile +10 -0
  8. data/bin/capify +89 -0
  9. data/bin/min +5 -0
  10. data/docs/lib-codebase-map.md +162 -0
  11. data/docs/lib-dependency-graph.svg +129 -0
  12. data/lib/minestrone/callback.rb +45 -0
  13. data/lib/minestrone/cli/help.rb +131 -0
  14. data/lib/minestrone/cli/help.txt +72 -0
  15. data/lib/minestrone/cli/options.rb +232 -0
  16. data/lib/minestrone/cli.rb +159 -0
  17. data/lib/minestrone/command.rb +177 -0
  18. data/lib/minestrone/configuration/actions/file_transfer.rb +53 -0
  19. data/lib/minestrone/configuration/actions/inspect.rb +46 -0
  20. data/lib/minestrone/configuration/actions/invocation.rb +202 -0
  21. data/lib/minestrone/configuration/alias_task.rb +29 -0
  22. data/lib/minestrone/configuration/callbacks.rb +129 -0
  23. data/lib/minestrone/configuration/connections.rb +66 -0
  24. data/lib/minestrone/configuration/execution.rb +139 -0
  25. data/lib/minestrone/configuration/loading.rb +207 -0
  26. data/lib/minestrone/configuration/log_formatters.rb +75 -0
  27. data/lib/minestrone/configuration/namespaces.rb +225 -0
  28. data/lib/minestrone/configuration/servers.rb +70 -0
  29. data/lib/minestrone/configuration/variables.rb +115 -0
  30. data/lib/minestrone/configuration.rb +69 -0
  31. data/lib/minestrone/errors.rb +17 -0
  32. data/lib/minestrone/ext/string.rb +7 -0
  33. data/lib/minestrone/extensions.rb +56 -0
  34. data/lib/minestrone/logger.rb +171 -0
  35. data/lib/minestrone/processable.rb +50 -0
  36. data/lib/minestrone/recipes/deploy/assets.rb +194 -0
  37. data/lib/minestrone/recipes/deploy/bundler.rb +81 -0
  38. data/lib/minestrone/recipes/deploy/dependencies.rb +44 -0
  39. data/lib/minestrone/recipes/deploy/local_dependency.rb +45 -0
  40. data/lib/minestrone/recipes/deploy/remote_dependency.rb +119 -0
  41. data/lib/minestrone/recipes/deploy/scm/base.rb +204 -0
  42. data/lib/minestrone/recipes/deploy/scm/git.rb +284 -0
  43. data/lib/minestrone/recipes/deploy/scm/none.rb +54 -0
  44. data/lib/minestrone/recipes/deploy/scm.rb +22 -0
  45. data/lib/minestrone/recipes/deploy/strategy/base.rb +87 -0
  46. data/lib/minestrone/recipes/deploy/strategy/copy.rb +353 -0
  47. data/lib/minestrone/recipes/deploy/strategy/remote_cache.rb +80 -0
  48. data/lib/minestrone/recipes/deploy/strategy.rb +22 -0
  49. data/lib/minestrone/recipes/deploy.rb +639 -0
  50. data/lib/minestrone/recipes/standard.rb +23 -0
  51. data/lib/minestrone/recipes/templates/maintenance.rhtml +53 -0
  52. data/lib/minestrone/server_definition.rb +56 -0
  53. data/lib/minestrone/ssh.rb +81 -0
  54. data/lib/minestrone/task_definition.rb +82 -0
  55. data/lib/minestrone/transfer.rb +205 -0
  56. data/lib/minestrone/version.rb +11 -0
  57. data/lib/minestrone.rb +3 -0
  58. data/minestrone.gemspec +32 -0
  59. data/test/cli/execute_test.rb +130 -0
  60. data/test/cli/help_test.rb +178 -0
  61. data/test/cli/options_test.rb +315 -0
  62. data/test/cli/ui_test.rb +26 -0
  63. data/test/cli_test.rb +17 -0
  64. data/test/command_test.rb +305 -0
  65. data/test/configuration/actions/file_transfer_test.rb +61 -0
  66. data/test/configuration/actions/inspect_test.rb +76 -0
  67. data/test/configuration/actions/invocation_test.rb +258 -0
  68. data/test/configuration/alias_task_test.rb +110 -0
  69. data/test/configuration/callbacks_test.rb +201 -0
  70. data/test/configuration/connections_test.rb +192 -0
  71. data/test/configuration/execution_test.rb +176 -0
  72. data/test/configuration/loading_test.rb +149 -0
  73. data/test/configuration/namespace_dsl_test.rb +325 -0
  74. data/test/configuration/servers_test.rb +100 -0
  75. data/test/configuration/variables_test.rb +191 -0
  76. data/test/configuration_test.rb +77 -0
  77. data/test/deploy/local_dependency_test.rb +61 -0
  78. data/test/deploy/remote_dependency_test.rb +146 -0
  79. data/test/deploy/scm/base_test.rb +55 -0
  80. data/test/deploy/scm/git_test.rb +260 -0
  81. data/test/deploy/scm/none_test.rb +26 -0
  82. data/test/deploy/strategy/copy_test.rb +360 -0
  83. data/test/extensions_test.rb +69 -0
  84. data/test/fixtures/cli_integration.rb +5 -0
  85. data/test/fixtures/config.rb +4 -0
  86. data/test/fixtures/custom.rb +3 -0
  87. data/test/logger_formatting_test.rb +149 -0
  88. data/test/logger_test.rb +134 -0
  89. data/test/recipes_test.rb +26 -0
  90. data/test/server_definition_test.rb +121 -0
  91. data/test/ssh_test.rb +99 -0
  92. data/test/task_definition_test.rb +117 -0
  93. data/test/transfer_test.rb +172 -0
  94. data/test/utils.rb +28 -0
  95. data/test/version_test.rb +11 -0
  96. metadata +258 -0
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'benchmark'
4
+ require 'minestrone/errors'
5
+ require 'minestrone/processable'
6
+
7
+ module Minestrone
8
+
9
+ #
10
+ # This class encapsulates a single command to be executed on a remote machine.
11
+ #
12
+
13
+ class Command
14
+ include Processable
15
+
16
+ attr_reader :command, :session, :options, :callback, :channel
17
+
18
+ def self.process(command, session, options = {}, &block)
19
+ new(command, session, options, &block).process!
20
+ end
21
+
22
+ # Instantiates a new command object. The +command+ must be a string
23
+ # containing the command to execute. +session+ is a Net::SSH session
24
+ # instance, and +options+ must be a hash containing any of the following keys:
25
+ #
26
+ # * +logger+: (optional), a Minestrone::Logger instance
27
+ # * +data+: (optional), a string to be sent to the command via it's stdin
28
+ # * +env+: (optional), a string or hash to be interpreted as environment
29
+ # variables that should be defined for this command invocation.
30
+
31
+ def initialize(command, session, options = {}, &block)
32
+ @command = command.strip.gsub(/\r?\n/, "\\\n")
33
+ @session = session
34
+ @options = options
35
+ @callback = block || Minestrone::Configuration.default_io_proc
36
+ end
37
+
38
+ # Processes the command. If the command fails (non-zero return code), this
39
+ # will raise a Minestrone::CommandError.
40
+
41
+ def process!
42
+ elapsed = Benchmark.realtime do
43
+ open_channel(session)
44
+
45
+ loop do
46
+ break unless process_iteration { !channel[:closed] }
47
+ end
48
+ end
49
+
50
+ logger.trace "command finished in #{(elapsed * 1000).round}ms" if logger
51
+
52
+ if channel[:status] != 0
53
+ message = "#{channel[:command].inspect} on #{channel[:server]}"
54
+ error = CommandError.new("failed: #{message}")
55
+ error.host = channel[:server]
56
+ raise error
57
+ end
58
+
59
+ self
60
+ end
61
+
62
+ # Force the command to stop processing, by closing the open channel
63
+ # associated with this command.
64
+
65
+ def stop!
66
+ channel.close if channel && !channel[:closed]
67
+ end
68
+
69
+
70
+ private
71
+
72
+ def logger
73
+ options[:logger]
74
+ end
75
+
76
+ def open_channel(session)
77
+ server = session.xserver
78
+ opened = nil
79
+
80
+ returned = session.open_channel do |channel|
81
+ opened = channel
82
+ channel[:server] = server
83
+ channel[:host] = server.host
84
+ channel[:options] = options
85
+ channel[:callback] = callback
86
+
87
+ request_pty_if_necessary(channel) do |ch, success|
88
+ if success
89
+ logger.trace "executing command", ch[:server] if logger
90
+ cmd = replace_placeholders(command, ch)
91
+
92
+ if options[:shell] == false
93
+ shell = nil
94
+ else
95
+ shell = "#{options[:shell] || "sh"} -c"
96
+ cmd = cmd.gsub(/'/) { |m| "'\\''" }
97
+ cmd = "'#{cmd}'"
98
+ end
99
+
100
+ command_line = [environment, shell, cmd].compact.join(" ")
101
+ ch[:command] = command_line
102
+
103
+ ch.exec(command_line)
104
+ ch.send_data(options[:data]) if options[:data]
105
+ ch.eof! if options[:eof]
106
+ else
107
+ # just log it, don't actually raise an exception, since the
108
+ # process method will see that the status is not zero and will
109
+ # raise an exception then.
110
+ logger.important "could not open channel", ch[:server] if logger
111
+ ch.close
112
+ end
113
+ end
114
+
115
+ channel.on_data do |ch, data|
116
+ ch[:callback][ch, :out, data]
117
+ end
118
+
119
+ channel.on_extended_data do |ch, type, data|
120
+ ch[:callback][ch, :err, data]
121
+ end
122
+
123
+ channel.on_request("exit-status") do |ch, data|
124
+ ch[:status] = data.read_long
125
+ end
126
+
127
+ channel.on_request("exit-signal") do |ch, data|
128
+ if logger
129
+ exit_signal = data.read_string
130
+ logger.important "command received signal #{exit_signal}", ch[:server]
131
+ end
132
+ end
133
+
134
+ channel.on_close do |ch|
135
+ ch[:closed] = true
136
+ end
137
+ end
138
+
139
+ @channel = returned || opened
140
+ end
141
+
142
+ def request_pty_if_necessary(channel)
143
+ if options[:pty]
144
+ channel.request_pty do |ch, success|
145
+ yield ch, success
146
+ end
147
+ else
148
+ yield channel, true
149
+ end
150
+ end
151
+
152
+ def replace_placeholders(command, channel)
153
+ command.gsub(/\$CAPISTRANO:HOST\$/, channel[:host])
154
+ end
155
+
156
+ # prepare a space-separated sequence of variables assignments
157
+ # intended to be prepended to a command, so the shell sets
158
+ # the environment before running the command.
159
+ # i.e.: options[:env] = {'PATH' => '/opt/ruby/bin:$PATH',
160
+ # 'TEST' => '( "quoted" )'}
161
+ # environment returns:
162
+ # "env TEST=(\ \"quoted\"\ ) PATH=/opt/ruby/bin:$PATH"
163
+
164
+ def environment
165
+ return if options[:env].nil? || options[:env].empty?
166
+
167
+ @environment ||= if String === options[:env]
168
+ "env #{options[:env]}"
169
+ else
170
+ options[:env].inject("env".dup) do |string, (name, value)|
171
+ value = value.to_s.gsub(/[ "]/) { |m| "\\#{m}" }
172
+ string << " #{name}=#{value}"
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minestrone/transfer'
4
+
5
+ module Minestrone
6
+ class Configuration
7
+ module Actions
8
+ module FileTransfer
9
+
10
+ # Store the given data at the given location on the configured server.
11
+ # If <tt>:mode</tt> is specified it is used to set the mode on the file.
12
+
13
+ def put(data, path, options = {})
14
+ opts = options.dup
15
+ upload(StringIO.new(data), path, opts)
16
+ end
17
+
18
+ # Get file remote_path from the configured server and transfer it to
19
+ # local machine as path.
20
+ #
21
+ # get "#{deploy_to}/current/log/production.log", "log/production.log.web"
22
+
23
+ def get(remote_path, path, options = {}, &block)
24
+ download(remote_path, path, options, &block)
25
+ end
26
+
27
+ def upload(from, to, options = {}, &block)
28
+ mode = options.delete(:mode)
29
+ transfer(:up, from, to, options, &block)
30
+
31
+ if mode
32
+ mode = mode.is_a?(Numeric) ? mode.to_s(8) : mode.to_s
33
+ run "chmod #{mode} #{to}", options
34
+ end
35
+ end
36
+
37
+ def download(from, to, options = {}, &block)
38
+ transfer(:down, from, to, options, &block)
39
+ end
40
+
41
+ def transfer(direction, from, to, options = {}, &block)
42
+ if dry_run
43
+ return logger.debug "transfering: #{[direction, from, to] * ', '}"
44
+ end
45
+
46
+ execute_on_server do
47
+ Transfer.process(direction, from, to, session, options.merge(:logger => logger), &block)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minestrone/errors'
4
+
5
+ module Minestrone
6
+ class Configuration
7
+ module Actions
8
+ module Inspect
9
+
10
+ # Streams the result of the command from the configured server.
11
+ # The command is invoked via #invoke_command.
12
+ #
13
+ # Usage:
14
+ #
15
+ # desc "Run a tail on multiple log files at the same time"
16
+ # task :tail_fcgi do
17
+ # stream "tail -f #{shared_path}/log/fastcgi.crash.log"
18
+ # end
19
+
20
+ def stream(command, options = {})
21
+ invoke_command(command, options.merge(:eof => !command.include?(sudo))) do |ch, stream, out|
22
+ puts out if stream == :out
23
+ warn "[err :: #{ch[:server]}] #{out}" if stream == :err
24
+ end
25
+ end
26
+
27
+ # Executes the given command on the first server targetted by the
28
+ # current task, collects it's stdout into a string, and returns the
29
+ # string. The command is invoked via #invoke_command.
30
+
31
+ def capture(command, options = {})
32
+ output = "".dup
33
+
34
+ invoke_command(command, options.merge(:once => true, :eof => !command.include?(sudo))) do |ch, stream, data|
35
+ case stream
36
+ when :out then output << data
37
+ when :err then warn "[err :: #{ch[:server]}] #{data}"
38
+ end
39
+ end
40
+
41
+ output
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minestrone/command'
4
+
5
+ module Minestrone
6
+ class Configuration
7
+ module Actions
8
+ module Invocation
9
+ def self.included(base) #:nodoc:
10
+ base.extend(ClassMethods)
11
+
12
+ base.default_io_proc = Proc.new do |ch, stream, out|
13
+ level = (stream == :err) ? :important : :info
14
+ ch[:options][:logger].send(level, out, "#{stream} :: #{ch[:server]}")
15
+ end
16
+ end
17
+
18
+ module ClassMethods
19
+ attr_accessor :default_io_proc
20
+ end
21
+
22
+ def initialize_invocation #:nodoc:
23
+ set :default_environment, {}
24
+ set :default_run_options, {}
25
+ end
26
+
27
+ # Invokes the given command. If a +via+ key is given, it will be used
28
+ # to determine what method to use to invoke the command. It defaults
29
+ # to :run, but may be :sudo, or any other method that conforms to the
30
+ # same interface as run and sudo.
31
+
32
+ def invoke_command(cmd, options = {}, &block)
33
+ options = options.dup
34
+ via = options.delete(:via) || :run
35
+ send(via, cmd, options, &block)
36
+ end
37
+
38
+ # Execute the given command on the configured server. If a block is
39
+ # given, it is invoked for all output
40
+ # generated by the command, and should accept three parameters: the SSH
41
+ # channel (which may be used to send data back to the remote process),
42
+ # the stream identifier (<tt>:err</tt> for stderr, and <tt>:out</tt> for
43
+ # stdout), and the data that was received.
44
+ #
45
+ # The +options+ hash may include any of the following keys:
46
+ #
47
+ # * :shell - says which shell should be used to invoke commands. This
48
+ # defaults to "sh". Setting this to false causes Minestrone to invoke
49
+ # the commands directly, without wrapping them in a shell invocation.
50
+ # * :data - if not nil (the default), this should be a string that will
51
+ # be passed to the command's stdin stream.
52
+ # * :pty - if true, a pseudo-tty will be allocated for each command. The
53
+ # default is false. Note that there are benefits and drawbacks both ways.
54
+ # Empirically, it appears that if a pty is allocated, the SSH server daemon
55
+ # will _not_ read user shell start-up scripts (e.g. bashrc, etc.). However,
56
+ # if a pty is _not_ allocated, some commands will refuse to run in
57
+ # interactive mode and will not prompt for (e.g.) passwords.
58
+ # * :env - a hash of environment variable mappings that should be made
59
+ # available to the command. The keys should be environment variable names,
60
+ # and the values should be their corresponding values. The default is
61
+ # empty, but may be modified by changing the +default_environment+
62
+ # Minestrone variable.
63
+ # * :eof - if true, the standard input stream will be closed after sending
64
+ # any data specified in the :data option. If false, the input stream is
65
+ # left open. The default is to close the input stream only if no block is
66
+ # passed.
67
+ #
68
+ # Note that if you set these keys in the +default_run_options+ Minestrone
69
+ # variable, they will apply for all invocations of #run and
70
+ # #invoke_command.
71
+
72
+ def run(cmd, options = {}, &block)
73
+ if options[:eof].nil? && !cmd.include?(sudo)
74
+ options = options.merge(:eof => !block_given?)
75
+ end
76
+
77
+ block ||= self.class.default_io_proc
78
+ options = add_default_command_options(options)
79
+
80
+ if cmd.nil? || cmd.empty?
81
+ raise ArgumentError, "attempt to execute without specifying a command"
82
+ end
83
+
84
+ logger.debug "executing #{cmd.inspect}" unless options[:silent]
85
+
86
+ return if dry_run || (debug && continue_execution(cmd) == false)
87
+
88
+ block = sudo_behavior_callback(block) if cmd.include?(sudo)
89
+
90
+ execute_on_server do
91
+ Command.process(cmd, session, options.merge(:logger => logger, :configuration => self), &block)
92
+ end
93
+ end
94
+
95
+ # Returns the command string used by minestrone to invoke a comamnd via
96
+ # sudo.
97
+ #
98
+ # run "#{sudo :as => 'bob'} mkdir /path/to/dir"
99
+ #
100
+ # It can also be invoked like #run, but executing the command via sudo.
101
+ # If sudo requires a password, set the <tt>:password</tt> variable or
102
+ # pass <tt>-p</tt> to prompt for it before running.
103
+ #
104
+ # sudo "mkdir /path/to/dir"
105
+ #
106
+ # Also, this method understands a <tt>:sudo</tt> configuration variable,
107
+ # which (if specified) will be used as the full path to the sudo
108
+ # executable on the remote machine:
109
+ #
110
+ # set :sudo, "/opt/local/bin/sudo"
111
+ #
112
+ # If you know what you're doing, you can also set <tt>:sudo_prompt</tt>,
113
+ # which tells minestrone which prompt sudo should use when asking for
114
+ # a password. (This is so that minestrone knows what prompt to look for
115
+ # in the output.) If you set :sudo_prompt to an empty string, Minestrone
116
+ # will not send a preferred prompt.
117
+
118
+ def sudo(*parameters, &block)
119
+ options = parameters.last.is_a?(Hash) ? parameters.pop.dup : {}
120
+ command = parameters.first
121
+ user = options[:as] && "-u #{options.delete(:as)}"
122
+
123
+ sudo_prompt_option = "-p '#{sudo_prompt}'" unless sudo_prompt.empty?
124
+ sudo_command = [fetch(:sudo, "sudo"), sudo_prompt_option, user].compact.join(" ")
125
+
126
+ if command
127
+ command = sudo_command + " " + command
128
+ run(command, options, &block)
129
+ else
130
+ return sudo_command
131
+ end
132
+ end
133
+
134
+ # Returns a Proc object that defines the behavior of the sudo
135
+ # callback. The returned Proc will defer to the +fallback+ argument
136
+ # (which should also be a Proc) for any output it does not
137
+ # explicitly handle.
138
+
139
+ def sudo_behavior_callback(fallback) #:nodoc:
140
+ # in order to prevent _each host_ from prompting when the password
141
+ # was wrong, let's track which host prompted first and only allow
142
+ # subsequent prompts from that host.
143
+ prompt_host = nil
144
+
145
+ Proc.new do |ch, stream, out|
146
+ if out.to_s =~ /^Sorry, try again/
147
+ if prompt_host.nil? || prompt_host == ch[:server]
148
+ prompt_host = ch[:server]
149
+ logger.important out, "#{stream} :: #{ch[:server]}"
150
+ reset! :password
151
+ end
152
+ end
153
+
154
+ if out.to_s =~ /^#{Regexp.escape(sudo_prompt)}/
155
+ ch.send_data "#{self[:password]}\n"
156
+ elsif fallback
157
+ fallback.call(ch, stream, out)
158
+ end
159
+ end
160
+ end
161
+
162
+ # Merges the various default command options into the options hash and
163
+ # returns the result. The default command options that are understand
164
+ # are:
165
+ #
166
+ # * :default_environment: If the :env key already exists, the :env
167
+ # key is merged into default_environment and then added back into
168
+ # options.
169
+ # * :default_shell: if the :shell key already exists, it will be used.
170
+ # Otherwise, if the :default_shell key exists in the configuration,
171
+ # it will be used. Otherwise, no :shell key is added.
172
+
173
+ def add_default_command_options(options)
174
+ defaults = self[:default_run_options]
175
+ options = defaults.merge(options)
176
+
177
+ env = self[:default_environment]
178
+ env = env.merge(options[:env]) if options[:env]
179
+ options[:env] = env unless env.empty?
180
+
181
+ shell = options[:shell] || self[:default_shell]
182
+ options[:shell] = shell unless shell.nil?
183
+
184
+ options
185
+ end
186
+
187
+ # Returns the prompt text to use with sudo
188
+ def sudo_prompt
189
+ fetch(:sudo_prompt, "sudo password: ")
190
+ end
191
+
192
+ def continue_execution(command)
193
+ case Minestrone::CLI.debug_prompt(command.inspect)
194
+ when "y" then true
195
+ when "n" then false
196
+ when "a" then exit(-1)
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minestrone
4
+ class Configuration
5
+ module AliasTask
6
+
7
+ # Attempts to find the task at the given fully-qualified path, and
8
+ # alias it. If arguments don't have correct task names, an ArgumentError
9
+ # will be raised. If no such task exists, a Minestrone::NoSuchTaskError
10
+ # will be raised.
11
+ #
12
+ # Usage:
13
+ #
14
+ # alias_task :original_deploy, :deploy
15
+
16
+ def alias_task(new_name, old_name)
17
+ if !new_name.respond_to?(:to_sym) || !old_name.respond_to?(:to_sym)
18
+ raise ArgumentError, "expected a valid task name"
19
+ end
20
+
21
+ original_task = find_task(old_name) or raise NoSuchTaskError, "the task `#{old_name}' does not exist"
22
+ task = original_task.dup # Duplicate task to avoid modify original task
23
+ task.name = new_name
24
+
25
+ define_task(task)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minestrone/callback'
4
+
5
+ module Minestrone
6
+ class Configuration
7
+ module Callbacks
8
+ def self.included(base) #:nodoc:
9
+ base.send :alias_method, :invoke_task_directly_without_callbacks, :invoke_task_directly
10
+ base.send :alias_method, :invoke_task_directly, :invoke_task_directly_with_callbacks
11
+ end
12
+
13
+ # The hash of callbacks that have been registered for this configuration
14
+ attr_reader :callbacks
15
+
16
+ def initialize_callbacks #:nodoc:
17
+ @callbacks = {}
18
+ end
19
+
20
+ def invoke_task_directly_with_callbacks(task) #:nodoc:
21
+ trigger :before, task
22
+ result = invoke_task_directly_without_callbacks(task)
23
+ trigger :after, task
24
+
25
+ return result
26
+ end
27
+
28
+ # Defines a callback to be invoked before the given task. You must
29
+ # specify the fully-qualified task name, both for the primary task, and
30
+ # for the task(s) to be executed before. Alternatively, you can pass a
31
+ # block to be executed before the given task.
32
+ #
33
+ # before "deploy:update_code", :record_difference
34
+ # before :deploy, "custom:log_deploy"
35
+ # before :deploy, :this, "then:this", "and:then:this"
36
+ # before :some_task do
37
+ # puts "an anonymous hook!"
38
+ # end
39
+ #
40
+ # This just provides a convenient interface to the more general #on method.
41
+
42
+ def before(task_name, *args, &block)
43
+ options = args.last.is_a?(Hash) ? args.pop : {}
44
+ args << options.merge(:only => task_name)
45
+
46
+ on :before, *args, &block
47
+ end
48
+
49
+ # Defines a callback to be invoked after the given task. You must
50
+ # specify the fully-qualified task name, both for the primary task, and
51
+ # for the task(s) to be executed after. Alternatively, you can pass a
52
+ # block to be executed after the given task.
53
+ #
54
+ # after "deploy:update_code", :log_difference
55
+ # after :deploy, "custom:announce"
56
+ # after :deploy, :this, "then:this", "and:then:this"
57
+ # after :some_task do
58
+ # puts "an anonymous hook!"
59
+ # end
60
+ #
61
+ # This just provides a convenient interface to the more general #on method.
62
+
63
+ def after(task_name, *args, &block)
64
+ options = args.last.is_a?(Hash) ? args.pop : {}
65
+ args << options.merge(:only => task_name)
66
+
67
+ on :after, *args, &block
68
+ end
69
+
70
+ # Defines one or more callbacks to be invoked in response to some event.
71
+ # Minestrone currently understands the following events:
72
+ #
73
+ # * :before, triggered before a task is invoked
74
+ # * :after, triggered after a task is invoked
75
+ # * :start, triggered before a top-level task is invoked via the command-line
76
+ # * :finish, triggered when a top-level task completes
77
+ # * :load, triggered after all recipes have loaded
78
+ # * :exit, triggered after all tasks have completed
79
+ #
80
+ # Specify the (fully-qualified) task names that you want invoked in
81
+ # response to the event. Alternatively, you can specify a block to invoke
82
+ # when the event is triggered. You can also pass a hash of options as the
83
+ # last parameter, which may include either of two keys:
84
+ #
85
+ # * :only, should specify an array of task names. Restricts this callback
86
+ # so that it will only fire when the event applies to those tasks.
87
+ # * :except, should specify an array of task names. Restricts this callback
88
+ # so that it will never fire when the event applies to those tasks.
89
+ #
90
+ # Usage:
91
+ #
92
+ # on :before, "some:hook", "another:hook", :only => "deploy:update"
93
+ # on :after, "some:hook", :except => "deploy:create_symlink"
94
+ # on :before, "global:hook"
95
+ # on :after, :only => :deploy do
96
+ # puts "after deploy here"
97
+ # end
98
+
99
+ def on(event, *args, &block)
100
+ options = args.last.is_a?(Hash) ? args.pop : {}
101
+ callbacks[event] ||= []
102
+
103
+ if args.empty? && block.nil?
104
+ raise ArgumentError, "please specify either a task name or a block to invoke"
105
+ elsif args.any? && block
106
+ raise ArgumentError, "please specify only a task name or a block, but not both"
107
+ elsif block
108
+ callbacks[event] << ProcCallback.new(block, options)
109
+ else
110
+ callbacks[event].concat(args.map { |name| TaskCallback.new(self, name, options) })
111
+ end
112
+ end
113
+
114
+ # Trigger the named event for the named task. All associated callbacks
115
+ # will be fired, in the order they were defined.
116
+
117
+ def trigger(event, task = nil)
118
+ pending = Array(callbacks[event]).select { |c| c.applies_to?(task) }
119
+
120
+ if pending.any?
121
+ msg = "triggering #{event} callbacks"
122
+ msg << " for `#{task.fully_qualified_name}'" if task
123
+ logger.trace(msg)
124
+ pending.each { |callback| callback.call }
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end