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,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ load 'deploy' unless defined?(_cset)
6
+
7
+ _cset :asset_env, "RAILS_GROUPS=assets"
8
+ _cset :assets_prefix, "assets"
9
+ _cset :shared_assets_prefix, "assets"
10
+ _cset :expire_assets_after, (3600 * 24 * 7)
11
+ _cset(:asset_manifest_prefix) { (`sprockets -v`.chomp < "3.0" ? "manifest" : ".sprockets-manifest") rescue "manifest" }
12
+
13
+ _cset :normalize_asset_timestamps, false
14
+
15
+ before 'deploy:finalize_update', 'deploy:assets:symlink'
16
+ after 'deploy:update_code', 'deploy:assets:precompile'
17
+ before 'deploy:assets:precompile', 'deploy:assets:update_asset_mtimes'
18
+ after 'deploy:cleanup', 'deploy:assets:clean_expired'
19
+ after 'deploy:rollback:revision', 'deploy:assets:rollback'
20
+
21
+ def shared_manifest_path
22
+ @shared_manifest_path ||= capture("ls #{shared_path.shellescape}/#{shared_assets_prefix}/#{asset_manifest_prefix}*").strip
23
+ end
24
+
25
+ # Parses manifest and returns array of uncompressed and compressed asset filenames with and without digests
26
+ # "Intelligently" determines format of string - supports YAML and JSON
27
+ def parse_manifest(str)
28
+ assets_hash = str[0,1] == '{' ? JSON.parse(str)['assets'] : YAML.load(str)
29
+
30
+ assets_hash.to_a.flatten.map {|a| [a, "#{a}.gz"] }.flatten
31
+ end
32
+
33
+ namespace :deploy do
34
+ namespace :assets do
35
+ desc <<-DESC
36
+ [internal] This task will set up a symlink to the shared directory \
37
+ for the assets directory. Assets are shared across deploys to avoid \
38
+ mid-deploy mismatches between old application html asking for assets \
39
+ and getting a 404 file not found error. The assets cache is shared \
40
+ for efficiency. If you customize the assets path prefix, override the \
41
+ :assets_prefix variable to match. If you customize shared assets path \
42
+ prefix, override :shared_assets_prefix variable to match.
43
+ DESC
44
+ task :symlink do
45
+ run <<-CMD.compact
46
+ rm -rf #{latest_release}/public/#{assets_prefix} &&
47
+ mkdir -p #{latest_release}/public &&
48
+ mkdir -p #{shared_path}/#{shared_assets_prefix} &&
49
+ ln -s #{shared_path}/#{shared_assets_prefix} #{latest_release}/public/#{assets_prefix}
50
+ CMD
51
+ end
52
+
53
+ desc <<-DESC
54
+ Run the asset precompilation rake task. You can specify the full path \
55
+ to the rake executable by setting the rake variable. You can also \
56
+ specify additional environment variables to pass to rake via the \
57
+ asset_env variable. The defaults are:
58
+
59
+ set :rake, "rake"
60
+ set :rails_env, "production"
61
+ set :asset_env, "RAILS_GROUPS=assets"
62
+ DESC
63
+ task :precompile do
64
+ run <<-CMD.compact
65
+ cd -- #{latest_release} &&
66
+ RAILS_ENV=#{rails_env.to_s.shellescape} #{asset_env} #{rake} assets:precompile
67
+ CMD
68
+
69
+ if capture("ls -1 #{shared_path.shellescape}/#{shared_assets_prefix}/#{asset_manifest_prefix}* | wc -l").to_i > 1
70
+ raise "More than one asset manifest file was found in '#{shared_path.shellescape}/#{shared_assets_prefix}'. If you are upgrading a Rails 3 application to Rails 4, follow these instructions: http://github.com/minestrone/minestrone/wiki/Upgrading-to-Rails-4#asset-pipeline"
71
+ end
72
+
73
+ # Sync manifest filenames if our manifest has a random filename
74
+ if shared_manifest_path =~ /#{asset_manifest_prefix}-.+\./
75
+ run <<-CMD.compact
76
+ [ -e #{shared_manifest_path.shellescape} ] || mv -- #{shared_path.shellescape}/#{shared_assets_prefix}/#{asset_manifest_prefix}* #{shared_manifest_path.shellescape}
77
+ CMD
78
+ end
79
+
80
+ # Copy manifest to release root (for clean_expired task)
81
+ run <<-CMD.compact
82
+ cp -- #{shared_manifest_path.shellescape} #{current_release.to_s.shellescape}/assets_manifest#{File.extname(shared_manifest_path)}
83
+ CMD
84
+ end
85
+
86
+ desc <<-DESC
87
+ [internal] Updates the mtimes for assets that are required by the current release.
88
+ This task runs before assets:precompile.
89
+ DESC
90
+ task :update_asset_mtimes do
91
+ # Fetch assets/manifest contents.
92
+ manifest_content = capture("[ -e '#{shared_path.shellescape}/#{shared_assets_prefix}/#{asset_manifest_prefix}*' ] && cat #{shared_path.shellescape}/#{shared_assets_prefix}/#{asset_manifest_prefix}* || echo").strip
93
+
94
+ if manifest_content != ""
95
+ current_assets = parse_manifest(manifest_content)
96
+ logger.info "Updating mtimes for ~#{current_assets.count} assets..."
97
+ put current_assets.map{|a| "#{shared_path}/#{shared_assets_prefix}/#{a}" }.join("\n"), "#{deploy_to}/TOUCH_ASSETS", :via => :scp
98
+ run <<-CMD.compact
99
+ cat #{deploy_to.shellescape}/TOUCH_ASSETS | while read asset; do
100
+ touch -c -- "$asset";
101
+ done &&
102
+ rm -f -- #{deploy_to.shellescape}/TOUCH_ASSETS
103
+ CMD
104
+ end
105
+ end
106
+
107
+ desc <<-DESC
108
+ Run the asset clean rake task. Use with caution, this will delete \
109
+ all of your compiled assets. You can specify the full path \
110
+ to the rake executable by setting the rake variable. You can also \
111
+ specify additional environment variables to pass to rake via the \
112
+ asset_env variable. The defaults are:
113
+
114
+ set :rake, "rake"
115
+ set :rails_env, "production"
116
+ set :asset_env, "RAILS_GROUPS=assets"
117
+ DESC
118
+ task :clean do
119
+ run "cd #{latest_release} && #{rake} RAILS_ENV=#{rails_env} #{asset_env} assets:clean"
120
+ end
121
+
122
+ desc <<-DESC
123
+ Clean up any assets that haven't been deployed for more than :expire_assets_after seconds.
124
+ Default time to keep old assets is one week. Set the :expire_assets_after variable
125
+ to change the assets expiry time. Assets will only be deleted if they are not required by
126
+ an existing release.
127
+ DESC
128
+ task :clean_expired do
129
+ # Fetch all assets_manifest contents.
130
+ manifests_output = capture <<-CMD.compact
131
+ for manifest in #{releases_path.shellescape}/*/assets_manifest.*; do
132
+ cat -- "$manifest" 2> /dev/null && printf ':::' || true;
133
+ done
134
+ CMD
135
+ manifests = manifests_output.split(':::')
136
+
137
+ if manifests.empty?
138
+ logger.info "No manifests in #{releases_path}/*/assets_manifest.*"
139
+ else
140
+ logger.info "Fetched #{manifests.count} manifests from #{releases_path}/*/assets_manifest.*"
141
+ current_assets = Set.new
142
+ manifests.each do |content|
143
+ current_assets += parse_manifest(content)
144
+ end
145
+ current_assets += [File.basename(shared_manifest_path), "sources_manifest.yml"]
146
+
147
+ # Write the list of required assets to server.
148
+ logger.info "Writing required assets to #{deploy_to}/REQUIRED_ASSETS..."
149
+ escaped_assets = current_assets.sort.join("\n").gsub("\"", "\\\"") << "\n"
150
+ put escaped_assets, "#{deploy_to}/REQUIRED_ASSETS", :via => :scp
151
+
152
+ # Finds all files older than X minutes, then removes them if they are not referenced
153
+ # in REQUIRED_ASSETS.
154
+ expire_after_mins = (expire_assets_after.to_f / 60.0).to_i
155
+ logger.info "Removing assets that haven't been deployed for #{expire_after_mins} minutes..."
156
+ # LC_COLLATE=C tells the `sort` and `comm` commands to sort files in byte order.
157
+ run <<-CMD.compact
158
+ cd -- #{deploy_to.shellescape}/ &&
159
+ LC_COLLATE=C sort REQUIRED_ASSETS -o REQUIRED_ASSETS &&
160
+ cd -- #{shared_path.shellescape}/#{shared_assets_prefix}/ &&
161
+ for f in $(
162
+ find * -mmin +#{expire_after_mins.to_s.shellescape} -type f | LC_COLLATE=C sort |
163
+ LC_COLLATE=C comm -23 -- - #{deploy_to.shellescape}/REQUIRED_ASSETS
164
+ ); do
165
+ echo "Removing unneeded asset: $f";
166
+ rm -f -- "$f";
167
+ done;
168
+ rm -f -- #{deploy_to.shellescape}/REQUIRED_ASSETS
169
+ CMD
170
+ end
171
+ end
172
+
173
+ desc <<-DESC
174
+ Rolls back assets to the previous release by symlinking the release's manifest
175
+ to shared/assets/manifest, and finally recompiling or regenerating nondigest assets.
176
+ DESC
177
+ task :rollback do
178
+ previous_manifest = capture("ls #{previous_release.shellescape}/assets_manifest.*").strip
179
+
180
+ if capture("[ -e #{previous_manifest.shellescape} ] && echo true || echo false").strip != 'true'
181
+ puts "#{previous_manifest} is missing! Cannot roll back assets. " <<
182
+ "Please run deploy:assets:precompile to update your assets when the rollback is finished."
183
+ else
184
+ restored_manifest_path = shared_manifest_path
185
+
186
+ run <<-CMD.compact
187
+ cd -- #{previous_release.shellescape} &&
188
+ cp -f -- #{previous_manifest.shellescape} #{restored_manifest_path.shellescape} &&
189
+ [ -z "$(#{rake} -P | grep assets:precompile:nondigest)" ] || #{rake} RAILS_ENV=#{rails_env.to_s.shellescape} #{asset_env} assets:precompile:nondigest
190
+ CMD
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Based on:
4
+ # https://github.com/ruby/rubygems/blob/master/bundler/lib/bundler/minestrone.rb
5
+ # and https://github.com/minestrone/bundler
6
+
7
+ load 'deploy' unless defined?(_cset)
8
+
9
+ _cset :bundle_env, ''
10
+ _cset :bundle_cmd, 'bundle'
11
+ _cset(:bundle_path) { "#{shared_path}/bundle" }
12
+ _cset :bundle_without, [:development, :test]
13
+ _cset :bundle_flags, '--quiet'
14
+
15
+ set(:rake) { "#{bundle_cmd} exec rake" }
16
+
17
+ before "deploy:finalize_update", "bundle:install"
18
+ before "bundle:install", "bundle:config"
19
+
20
+ namespace :bundle do
21
+ desc <<-DESC
22
+ Sets up the Bundler configuration appropriate for a production environment.
23
+ You can customize the settings using the following variables:
24
+
25
+ set :bundle_cmd, 'bundle' # e.g. '/usr/local/bin/bundle
26
+ set(:bundle_path) { shared_path + "/bundle" }
27
+ set :bundle_without, [:development, :test]
28
+ DESC
29
+
30
+ task :config do
31
+ bundle_cmd = fetch(:bundle_cmd)
32
+ bundle_path = fetch(:bundle_path)
33
+
34
+ without = fetch(:bundle_without)
35
+ without = [without] unless without.is_a?(Array)
36
+
37
+ settings = [
38
+ ['deployment', 'true'],
39
+ ['path', bundle_path],
40
+ ['without', without.map(&:to_s).join(' ')],
41
+ ]
42
+
43
+ settings.each do |key, value|
44
+ run "cd #{release_path} && #{bundle_cmd} config set --local #{key} '#{value}'"
45
+ end
46
+ end
47
+
48
+ desc <<-DESC
49
+ Installs the gems required by the app. By default, gems are installed to
50
+ \"\#{shared_path}/bundle\", with :development and :test groups skipped.
51
+
52
+ You can customize the settings using the following variables:
53
+
54
+ set :bundle_env, '' # e.g. 'SOME_LIBRARY_PATH=/usr/local'
55
+ set :bundle_cmd, 'bundle' # e.g. '/usr/local/bin/bundle
56
+ set(:bundle_path) { shared_path + "/bundle" }
57
+ set :bundle_without, [:development, :test]
58
+ set :bundle_flags, '--quiet'
59
+ DESC
60
+
61
+ task :install do
62
+ bundle_env = fetch(:bundle_env)
63
+ bundle_cmd = fetch(:bundle_cmd)
64
+ bundle_flags = fetch(:bundle_flags)
65
+
66
+ bundle_env += " " unless bundle_env.to_s.empty?
67
+
68
+ run "cd #{release_path} && #{bundle_env}#{bundle_cmd} install #{bundle_flags}"
69
+ end
70
+
71
+ desc <<-DESC
72
+ Cleans up older versions of gems in the shared bundle folder. This removes all
73
+ gems that aren't currently referenced by the Gemfile.lock.
74
+ DESC
75
+
76
+ task :clean do
77
+ bundle_cmd = fetch(:bundle_cmd, "bundle")
78
+
79
+ run "cd #{current_path} && #{bundle_cmd} clean"
80
+ end
81
+ end
@@ -0,0 +1,44 @@
1
+ require 'minestrone/recipes/deploy/local_dependency'
2
+ require 'minestrone/recipes/deploy/remote_dependency'
3
+
4
+ module Minestrone
5
+ module Deploy
6
+ class Dependencies
7
+ include Enumerable
8
+
9
+ attr_reader :configuration
10
+
11
+ def initialize(configuration)
12
+ @configuration = configuration
13
+ @dependencies = []
14
+ yield self if block_given?
15
+ end
16
+
17
+ def check
18
+ yield self
19
+ self
20
+ end
21
+
22
+ def remote
23
+ dep = RemoteDependency.new(configuration)
24
+ @dependencies << dep
25
+ dep
26
+ end
27
+
28
+ def local
29
+ dep = LocalDependency.new(configuration)
30
+ @dependencies << dep
31
+ dep
32
+ end
33
+
34
+ def each
35
+ @dependencies.each { |d| yield d }
36
+ self
37
+ end
38
+
39
+ def pass?
40
+ all? { |d| d.pass? }
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minestrone
4
+ module Deploy
5
+ class LocalDependency
6
+ attr_reader :configuration
7
+ attr_reader :message
8
+
9
+ def initialize(configuration)
10
+ @configuration = configuration
11
+ @success = true
12
+ end
13
+
14
+ def command(command)
15
+ @message ||= "`#{command}' could not be found in the path on the local host"
16
+ @success = find_in_path(command)
17
+ self
18
+ end
19
+
20
+ def or(message)
21
+ @message = message
22
+ self
23
+ end
24
+
25
+ def pass?
26
+ @success
27
+ end
28
+
29
+ private
30
+
31
+ # Searches the path, looking for the given utility. If an executable
32
+ # file is found that matches the parameter, this returns true.
33
+ def find_in_path(utility)
34
+ path = (ENV['PATH'] || "").split(File::PATH_SEPARATOR)
35
+
36
+ path.each do |dir|
37
+ file = File.join(dir, utility)
38
+ return true if File.executable?(file)
39
+ end
40
+
41
+ false
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minestrone/errors'
4
+
5
+ module Minestrone
6
+ module Deploy
7
+ class RemoteDependency
8
+ attr_reader :configuration
9
+ attr_reader :hosts
10
+
11
+ def initialize(configuration)
12
+ @configuration = configuration
13
+ @success = true
14
+ @hosts = nil
15
+ end
16
+
17
+ def directory(path, options = {})
18
+ @message ||= "`#{path}' is not a directory"
19
+ try("test -d #{path}", options)
20
+ self
21
+ end
22
+
23
+ def file(path, options = {})
24
+ @message ||= "`#{path}' is not a file"
25
+ try("test -f #{path}", options)
26
+ self
27
+ end
28
+
29
+ def writable(path, options = {})
30
+ @message ||= "`#{path}' is not writable"
31
+ try("test -w #{path}", options)
32
+ self
33
+ end
34
+
35
+ def command(command, options = {})
36
+ @message ||= "`#{command}' could not be found in the path"
37
+ try("which #{command}", options)
38
+ self
39
+ end
40
+
41
+ def gem(name, version, options = {})
42
+ @message ||= "gem `#{name}' #{version} could not be found"
43
+ gem_cmd = configuration.fetch(:gem_command, "gem")
44
+ try("#{gem_cmd} specification --version '#{version}' #{name} 2>&1 | awk 'BEGIN { s = 0 } /^name:/ { s = 1; exit }; END { if(s == 0) exit 1 }'", options)
45
+ self
46
+ end
47
+
48
+ def deb(name, version, options = {})
49
+ @message ||= "package `#{name}' #{version} could not be found"
50
+ try("dpkg -s #{name} | grep '^Version: #{version}'", options)
51
+ self
52
+ end
53
+
54
+ def rpm(name, version, options = {})
55
+ @message ||= "package `#{name}' #{version} could not be found"
56
+ try("rpm -q #{name} | grep '#{version}'", options)
57
+ self
58
+ end
59
+
60
+ def match(command, expect, options = {})
61
+ expect = Regexp.new(Regexp.escape(expect.to_s)) unless expect.is_a?(Regexp)
62
+
63
+ output_per_server = {}
64
+ try("#{command} ", options) do |ch, stream, out|
65
+ output_per_server[ch[:server]] ||= ''
66
+ output_per_server[ch[:server]] += out
67
+ end
68
+
69
+ # It is possible for some of these commands to return a status != 0
70
+ # (for example, rake --version exits with a 1). For this check we
71
+ # just care if the output matches, so we reset the success flag.
72
+ @success = true
73
+
74
+ errored_hosts = []
75
+ output_per_server.each_pair do |server, output|
76
+ next if output =~ expect
77
+ errored_hosts << server
78
+ end
79
+
80
+ if errored_hosts.any?
81
+ @hosts = errored_hosts.join(', ')
82
+ output = output_per_server[errored_hosts.first]
83
+ @message = "the output #{output.inspect} from #{command.inspect} did not match #{expect.inspect}"
84
+ @success = false
85
+ end
86
+
87
+ self
88
+ end
89
+
90
+ def or(message)
91
+ @message = message
92
+ self
93
+ end
94
+
95
+ def pass?
96
+ @success
97
+ end
98
+
99
+ def message
100
+ s = @message.dup
101
+ s << " (#{@hosts})" if @hosts
102
+ s
103
+ end
104
+
105
+ private
106
+
107
+ def try(command, options)
108
+ return unless @success # short-circuit evaluation
109
+ configuration.invoke_command(command, options) do |ch,stream,out|
110
+ warn "#{ch[:server]}: #{out}" if stream == :err
111
+ yield ch, stream, out if block_given?
112
+ end
113
+ rescue Minestrone::CommandError => e
114
+ @success = false
115
+ @hosts = e.host.to_s
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minestrone
4
+ module Deploy
5
+ module SCM
6
+
7
+ # The ancestor class for all Minestrone SCM implementations. It provides
8
+ # minimal infrastructure for subclasses to build upon and override.
9
+ #
10
+ # Note that subclasses that implement this abstract class only return
11
+ # the commands that need to be executed--they do not execute the commands
12
+ # themselves. In this way, the deployment method may execute the commands
13
+ # either locally or remotely, as necessary.
14
+
15
+ class Base
16
+ class << self
17
+ # If no parameters are given, it returns the current configured
18
+ # name of the command-line utility of this SCM. If a parameter is
19
+ # given, the defeault command is set to that value.
20
+ def default_command(value = nil)
21
+ if value
22
+ @default_command = value
23
+ else
24
+ @default_command
25
+ end
26
+ end
27
+ end
28
+
29
+ # Wraps an SCM instance and forces all messages sent to it to be
30
+ # relayed to the underlying SCM instance, in "local" mode. See
31
+ # Base#local.
32
+ class LocalProxy
33
+ def initialize(scm)
34
+ @scm = scm
35
+ end
36
+
37
+ def method_missing(sym, *args, &block)
38
+ @scm.local { return @scm.send(sym, *args, &block) }
39
+ end
40
+ end
41
+
42
+ # The options available for this SCM instance to reference. Should be
43
+ # treated like a hash.
44
+ attr_reader :configuration
45
+
46
+ # Creates a new SCM instance with the given configuration options.
47
+ def initialize(configuration = {})
48
+ @configuration = configuration
49
+ end
50
+
51
+ # Returns a proxy that wraps the SCM instance and forces it to operate
52
+ # in "local" mode, which changes how variables are looked up in the
53
+ # configuration. Normally, if the value of a variable "foo" is needed,
54
+ # it is queried for in the configuration as "foo". However, in "local"
55
+ # mode, first "local_foo" would be looked for, and only if it is not
56
+ # found would "foo" be used. This allows for both (e.g.) "scm_command"
57
+ # and "local_scm_command" to be set, if the two differ.
58
+ #
59
+ # Alternatively, it may be called with a block, and for the duration of
60
+ # the block, all requests on this configuration object will be
61
+ # considered local.
62
+ def local
63
+ if block_given?
64
+ begin
65
+ saved, @local_mode = @local_mode, true
66
+ yield
67
+ ensure
68
+ @local_mode = saved
69
+ end
70
+ else
71
+ LocalProxy.new(self)
72
+ end
73
+ end
74
+
75
+ # Returns true if running in "local" mode. See #local.
76
+ def local?
77
+ @local_mode
78
+ end
79
+
80
+ # Returns the string used to identify the latest revision in the
81
+ # repository. This will be passed as the "revision" parameter of
82
+ # the methods below.
83
+ def head
84
+ raise NotImplementedError, "`head' is not implemented by #{self.class.name}"
85
+ end
86
+
87
+ # Checkout a copy of the repository, at the given +revision+, to the
88
+ # given +destination+. The checkout is suitable for doing development
89
+ # work in, e.g. allowing subsequent commits and updates.
90
+ def checkout(revision, destination)
91
+ raise NotImplementedError, "`checkout' is not implemented by #{self.class.name}"
92
+ end
93
+
94
+ # Resynchronize the working copy in +destination+ to the specified
95
+ # +revision+.
96
+ def sync(revision, destination)
97
+ raise NotImplementedError, "`sync' is not implemented by #{self.class.name}"
98
+ end
99
+
100
+ # Compute the difference between the two revisions, +from+ and +to+.
101
+ def diff(from, to = nil)
102
+ raise NotImplementedError, "`diff' is not implemented by #{self.class.name}"
103
+ end
104
+
105
+ # Return a log of all changes between the two specified revisions,
106
+ # +from+ and +to+, inclusive.
107
+ def log(from, to = nil)
108
+ raise NotImplementedError, "`log' is not implemented by #{self.class.name}"
109
+ end
110
+
111
+ # If the given revision represents a "real" revision, this should
112
+ # simply return the revision value. If it represends a pseudo-revision
113
+ # (like Subversions "HEAD" identifier), it should yield a string
114
+ # containing the commands that, when executed will return a string
115
+ # that this method can then extract the real revision from.
116
+ def query_revision(revision)
117
+ raise NotImplementedError, "`query_revision' is not implemented by #{self.class.name}"
118
+ end
119
+
120
+ # Returns the revision number immediately following revision, if at
121
+ # all possible. A block should always be passed to this method, which
122
+ # accepts a command to invoke and returns the result, although a
123
+ # particular SCM's implementation is not required to invoke the block.
124
+ #
125
+ # By default, this method simply returns the revision itself. If a
126
+ # particular SCM is able to determine a subsequent revision given a
127
+ # revision identifier, it should override this method.
128
+ def next_revision(revision)
129
+ revision
130
+ end
131
+
132
+ # Should analyze the given text and determine whether or not a
133
+ # response is expected, and if so, return the appropriate response.
134
+ # If no response is expected, return nil. The +state+ parameter is a
135
+ # hash that may be used to preserve state between calls. This method
136
+ # is used to define how Minestrone should respond to common prompts
137
+ # and messages from the SCM, like password prompts and such. By
138
+ # default, the output is simply displayed.
139
+ def handle_data(state, stream, text)
140
+ logger.info "[#{stream}] #{text}"
141
+ nil
142
+ end
143
+
144
+ # Returns the name of the command-line utility for this SCM. It first
145
+ # looks at the :scm_command variable, and if it does not exist, it
146
+ # then falls back to whatever was defined by +default_command+.
147
+ #
148
+ # If scm_command is set to :default, the default_command will be
149
+ # returned.
150
+ def command
151
+ command = variable(:scm_command)
152
+ command = nil if command == :default
153
+ command || default_command
154
+ end
155
+
156
+ # A helper method that can be used to define SCM commands naturally.
157
+ # It returns a single string with all arguments joined by spaces,
158
+ # with the scm command prefixed onto it.
159
+ def scm(*args)
160
+ [command, *args].compact.join(" ")
161
+ end
162
+
163
+ private
164
+
165
+ # A helper for accessing variable values, which takes into
166
+ # consideration the current mode ("normal" vs. "local").
167
+ def variable(name, default = nil)
168
+ if local? && configuration.exists?("local_#{name}".to_sym)
169
+ return configuration["local_#{name}".to_sym].nil? ? default : configuration["local_#{name}".to_sym]
170
+ else
171
+ configuration[name].nil? ? default : configuration[name]
172
+ end
173
+ end
174
+
175
+ # A reference to a Logger instance that the SCM can use to log
176
+ # activity.
177
+ def logger
178
+ @logger ||= variable(:logger) || Minestrone::Logger.new(:output => STDOUT)
179
+ end
180
+
181
+ # A helper for accessing the default command name for this SCM. It
182
+ # simply delegates to the class' +default_command+ method.
183
+ def default_command
184
+ self.class.default_command
185
+ end
186
+
187
+ # A convenience method for accessing the declared repository value.
188
+ def repository
189
+ variable(:repository)
190
+ end
191
+
192
+ def arguments(command = :all)
193
+ value = variable(:scm_arguments)
194
+
195
+ if value.is_a?(Hash)
196
+ value = value[command]
197
+ end
198
+
199
+ value
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end