luban 0.2.0

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 (61) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +37 -0
  6. data/Rakefile +1 -0
  7. data/bin/console +14 -0
  8. data/bin/setup +7 -0
  9. data/exe/luban +3 -0
  10. data/lib/luban/deployment/cli/application/authenticator.rb +106 -0
  11. data/lib/luban/deployment/cli/application/base.rb +179 -0
  12. data/lib/luban/deployment/cli/application/builder.rb +67 -0
  13. data/lib/luban/deployment/cli/application/publisher.rb +215 -0
  14. data/lib/luban/deployment/cli/application/repository.rb +175 -0
  15. data/lib/luban/deployment/cli/application/scm/git.rb +49 -0
  16. data/lib/luban/deployment/cli/application/scm/rsync.rb +47 -0
  17. data/lib/luban/deployment/cli/application.rb +5 -0
  18. data/lib/luban/deployment/cli/command.rb +360 -0
  19. data/lib/luban/deployment/cli/package/binary.rb +241 -0
  20. data/lib/luban/deployment/cli/package/dependency.rb +49 -0
  21. data/lib/luban/deployment/cli/package/dependency_set.rb +71 -0
  22. data/lib/luban/deployment/cli/package/installer/core.rb +98 -0
  23. data/lib/luban/deployment/cli/package/installer/install.rb +330 -0
  24. data/lib/luban/deployment/cli/package/installer/paths.rb +81 -0
  25. data/lib/luban/deployment/cli/package/installer.rb +3 -0
  26. data/lib/luban/deployment/cli/package/service.rb +17 -0
  27. data/lib/luban/deployment/cli/package/worker.rb +43 -0
  28. data/lib/luban/deployment/cli/package.rb +6 -0
  29. data/lib/luban/deployment/cli/project.rb +94 -0
  30. data/lib/luban/deployment/cli.rb +4 -0
  31. data/lib/luban/deployment/configuration/core.rb +67 -0
  32. data/lib/luban/deployment/configuration/filter.rb +54 -0
  33. data/lib/luban/deployment/configuration/question.rb +38 -0
  34. data/lib/luban/deployment/configuration/server.rb +70 -0
  35. data/lib/luban/deployment/configuration/server_set.rb +86 -0
  36. data/lib/luban/deployment/configuration.rb +5 -0
  37. data/lib/luban/deployment/error.rb +5 -0
  38. data/lib/luban/deployment/helpers/configuration.rb +159 -0
  39. data/lib/luban/deployment/helpers/utils.rb +180 -0
  40. data/lib/luban/deployment/helpers.rb +2 -0
  41. data/lib/luban/deployment/packages/bundler.rb +81 -0
  42. data/lib/luban/deployment/packages/git.rb +37 -0
  43. data/lib/luban/deployment/packages/openssl.rb +59 -0
  44. data/lib/luban/deployment/packages/ruby.rb +125 -0
  45. data/lib/luban/deployment/packages/rubygems.rb +89 -0
  46. data/lib/luban/deployment/packages/yaml.rb +33 -0
  47. data/lib/luban/deployment/parameters.rb +160 -0
  48. data/lib/luban/deployment/runner.rb +99 -0
  49. data/lib/luban/deployment/templates/envrc.erb +30 -0
  50. data/lib/luban/deployment/templates/unset_envrc.erb +28 -0
  51. data/lib/luban/deployment/version.rb +5 -0
  52. data/lib/luban/deployment/worker/base.rb +71 -0
  53. data/lib/luban/deployment/worker/controller.rb +11 -0
  54. data/lib/luban/deployment/worker/local.rb +19 -0
  55. data/lib/luban/deployment/worker/remote.rb +55 -0
  56. data/lib/luban/deployment/worker/task.rb +25 -0
  57. data/lib/luban/deployment/worker.rb +4 -0
  58. data/lib/luban/deployment.rb +8 -0
  59. data/lib/luban.rb +4 -0
  60. data/luban.gemspec +29 -0
  61. metadata +174 -0
@@ -0,0 +1,175 @@
1
+ module Luban
2
+ module Deployment
3
+ class Application
4
+ class Repository < Luban::Deployment::Worker::Local
5
+ using Luban::CLI::CoreRefinements
6
+
7
+ DefaultRevisionSize = 12
8
+
9
+ attr_reader :name
10
+ attr_reader :from
11
+ attr_reader :scm
12
+ attr_reader :revision
13
+ attr_reader :rev_size
14
+
15
+ def scm_module
16
+ require_relative "scm/#{scm}"
17
+ @scm_module ||= SCM.const_get(scm.camelcase)
18
+ end
19
+
20
+ def workspace_path
21
+ @workspace_path ||= app_path.join('.luban')
22
+ end
23
+
24
+ def clone_path
25
+ @clone_path ||= workspace_path.join('repositories').join(name)
26
+ end
27
+
28
+ def releases_path
29
+ @releases_path ||= workspace_path.join('releases').join(name)
30
+ end
31
+
32
+ def release_package_path
33
+ @release_package_path ||= releases_path.join(release_package_file_name)
34
+ end
35
+
36
+ def release_package_file_name
37
+ @release_package_file_name ||= "#{release_package_name}.#{release_package_extname}"
38
+ end
39
+
40
+ def release_package_name
41
+ @release_package_name ||= "#{release_prefix}-#{release_tag}"
42
+ end
43
+
44
+ def release_package_extname
45
+ @release_package_extname ||= 'tgz'
46
+ end
47
+
48
+ def release_prefix
49
+ @release_prefix ||= "#{stage}-#{project}-#{application}-#{name}"
50
+ end
51
+
52
+ def release_tag
53
+ @release_tag ||= "#{stage}-#{revision}"
54
+ end
55
+
56
+ def bundle_without
57
+ @bundle_without ||= %w(development test)
58
+ end
59
+
60
+ # Description on abstract methods:
61
+ # available?: check if the remote repository is available
62
+ # cloned?: check if the remote repository is cloned locally
63
+ # fetch_revision: retrieve the signature/checksum of the commit that will be deployed
64
+ # clone: clone a new copy of the remote repository
65
+ # update: update the clone of the remote repository
66
+ # release: copy the contents of cloned repository onto the release path
67
+ [:available?, :cloned?, :fetch_revision, :clone, :update, :release].each do |method|
68
+ define_method(method) do
69
+ raise NotImplementedError, "\#{self.class.name}##{__method__} is an abstract method."
70
+ end
71
+ end
72
+
73
+ def build
74
+ assure_dirs(clone_path, releases_path)
75
+ if cloned? and !force?
76
+ update_revision
77
+ update_result "Skipped! Local #{name} repository has been built ALREADY.", status: :skipped
78
+ else
79
+ if available?
80
+ if build!
81
+ update_revision
82
+ update_result "Successfully built local #{name} repository."
83
+ else
84
+ update_result "FAILED to build local #{name} repository!", status: :failed, level: :error
85
+ end
86
+ else
87
+ update_result "Aborted! Remote #{name} repository is NOT available.", status: :failed, level: :error
88
+ end
89
+ end
90
+ end
91
+
92
+ def package
93
+ if cloned?
94
+ if package!
95
+ cleanup_releases
96
+ update_result "Successfully package local #{name} repository to #{release_package_path}.",
97
+ release: { name: name, tag: release_tag,
98
+ path: release_package_path,
99
+ md5: md5_for_file(release_package_path),
100
+ bundled_gems: bundle_gems }
101
+ else
102
+ update_result "FAILED to package local #{name} repository!", status: :failed, level: :error
103
+ end
104
+ else
105
+ update_result "Aborted! Local #{name} package is NOT built yet!", status: :failed, level: :error
106
+ end
107
+ end
108
+
109
+ protected
110
+
111
+ def init
112
+ @rev_size = DefaultRevisionSize
113
+ task.opts.repository.each_pair { |name, value| instance_variable_set("@#{name}", value) }
114
+ load_scm
115
+ end
116
+
117
+ def load_scm
118
+ singleton_class.send(:prepend, scm_module)
119
+ end
120
+
121
+ def build!
122
+ rmdir(clone_path)
123
+ clone
124
+ end
125
+
126
+ def package!
127
+ if update
128
+ update_revision
129
+ release
130
+ end
131
+ end
132
+
133
+ def update_revision
134
+ @revision = fetch_revision
135
+ end
136
+
137
+ def cleanup_releases
138
+ files = capture(:ls, '-xt', releases_path).split
139
+ if files.count > keep_releases
140
+ within(releases_path) do
141
+ files.last(files.count - keep_releases).each { |f| rm(f) }
142
+ end
143
+ end
144
+ end
145
+
146
+ def bundle_gems
147
+ gemfile_path = Pathname.new(release_tag).join('Gemfile')
148
+ gems_cache = Pathname.new('vendor').join('cache')
149
+ bundle_path = Pathname.new('vendor').join('bundle')
150
+ bundled_gems = {}
151
+ gems = bundled_gems[:gems] = {}
152
+ if test(:tar, "-tzf #{release_package_path} #{gemfile_path} > /dev/null 2>&1")
153
+ within(workspace_path) do
154
+ execute(:tar, "--strip-components=1 -xzf #{release_package_path} #{gemfile_path}")
155
+ unless test(:bundle, :check, "--path #{bundle_path}")
156
+ execute(:bundle, :install, "--path #{bundle_path} --without #{bundle_without.join(' ')} --quiet")
157
+ info "Package gems bundled in Gemfile"
158
+ execute(:bundle, :package, "--all --quiet")
159
+ end
160
+ gem_files = capture(:ls, '-xt', gems_cache).split
161
+ gem_files.each do |gem_file|
162
+ gems[gem_file] = md5_for_file(gems_cache.join(gem_file))
163
+ end
164
+ end
165
+ bundled_gems[:gems_cache] = workspace_path.join(gems_cache)
166
+ workspace_path.join('Gemfile.lock').tap do |p|
167
+ bundled_gems[:locked_gemfile] = { path: p, md5: md5_for_file(p) }
168
+ end
169
+ end
170
+ bundled_gems
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,49 @@
1
+ module Luban
2
+ module Deployment
3
+ class Application
4
+ class Repository
5
+ module SCM
6
+ module Git
7
+ attr_reader :tag
8
+ attr_reader :branch
9
+
10
+ def git_cmd; :git; end
11
+
12
+ def ref
13
+ tag || branch || @ref
14
+ end
15
+
16
+ def available?
17
+ test(git_cmd, 'ls-remote --heads', from)
18
+ end
19
+
20
+ def cloned?
21
+ file?(clone_path.join("HEAD"))
22
+ end
23
+
24
+ def fetch_revision
25
+ within(clone_path) { capture(git_cmd, "rev-parse --short=#{rev_size} #{ref}") }
26
+ #within(clone_path) { capture(git_cmd, "rev-list --max-count=1 --abbrev-commit --abbrev=rev_size #{ref}") }
27
+ end
28
+
29
+ def clone
30
+ test(git_cmd, :clone, '--mirror', from, clone_path)
31
+ end
32
+
33
+ def update
34
+ within(clone_path) { test(git_cmd, :remote, :update, "--prune") }
35
+ end
36
+
37
+ def release
38
+ within(clone_path) { test(git_cmd, :archive, ref, "--prefix=#{release_tag}/ -o #{release_package_path}") }
39
+ end
40
+
41
+ def release_tag
42
+ @release_tag ||= "#{ref}-#{revision}"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,47 @@
1
+ module Luban
2
+ module Deployment
3
+ class Application
4
+ class Repository
5
+ module SCM
6
+ module Rsync
7
+ def init
8
+ super
9
+ @from = Pathname.new(@from) unless from.is_a?(Pathname)
10
+ end
11
+
12
+ def rsync_cmd; :rsync; end
13
+
14
+ def available?; directory?(from); end
15
+
16
+ def cloned?
17
+ directory?(clone_path) and
18
+ test("[ \"$(ls -A #{clone_path})\" ]") # Not empty
19
+ end
20
+
21
+ def fetch_revision
22
+ # Use MD5 as the revision
23
+ capture(:tar, "-cf - #{dir} 2>/dev/null | openssl md5")[0, rev_size]
24
+ end
25
+
26
+ def clone
27
+ test(rsync_cmd, "-acz", "#{from}/", clone_path)
28
+ end
29
+
30
+ def update
31
+ test(rsync_cmd, "-acz", "--delete", "#{from}/", clone_path)
32
+ end
33
+
34
+ def release
35
+ within(releases_path) do
36
+ assure_dirs(release_tag)
37
+ execute(:tar, "-C #{clone_path} -cf - . | tar -C #{release_tag} -xf -")
38
+ execute(:tar, "-czf", release_package_path, release_tag)
39
+ rm(release_tag)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,5 @@
1
+ require_relative 'application/base'
2
+ require_relative 'application/authenticator'
3
+ require_relative 'application/builder'
4
+ require_relative 'application/repository'
5
+ require_relative 'application/publisher'
@@ -0,0 +1,360 @@
1
+ module Luban
2
+ module Deployment
3
+ class Command < Luban::CLI::Command
4
+ module Tasks
5
+ module Install
6
+ %i(build destroy cleanup binstubs
7
+ show_current show_summary which whence).each do |action|
8
+ define_method(action) do |args:, opts:|
9
+ raise NotImplementedError, "#{self.class.name}##{__method__} is an abstract method."
10
+ end
11
+ end
12
+
13
+ def installable?; true; end
14
+
15
+ protected
16
+
17
+ def setup_install_tasks
18
+ _self = self
19
+ task :build do
20
+ desc "Build #{_self.display_name} environment"
21
+ switch :force, "Force to build", short: :f
22
+ action! :build
23
+ end
24
+
25
+ task :destroy do
26
+ desc "Destroy #{_self.display_name} environment"
27
+ switch :force, "Force to destroy", short: :f, required: true
28
+ action! :destroy
29
+ end
30
+
31
+ task :cleanup do
32
+ desc "Clean up temporary files during installation"
33
+ action! :cleanup
34
+ end
35
+
36
+ task :binstubs do
37
+ desc "Update binstubs for required packages"
38
+ switch :force, "Force to update binstubs", short: :f
39
+ action! :binstubs
40
+ end
41
+
42
+ task :version do
43
+ desc "Show current version for required packages"
44
+ action! :show_current
45
+ end
46
+
47
+ task :versions do
48
+ desc "Show package installation summary"
49
+ action! :show_summary
50
+ end
51
+
52
+ task :which do
53
+ desc "Show the real path for the given executable"
54
+ argument :executable, "Executable to which", short: :e, required: true
55
+ action! :which
56
+ end
57
+
58
+ task :whence do
59
+ desc "List packages with the given executable"
60
+ argument :executable, "Executable to whence", short: :e, required: true
61
+ action! :whence
62
+ end
63
+ end
64
+ end
65
+
66
+ module Deploy
67
+ %i(deploy rollback).each do |action|
68
+ define_method(action) do |args:, opts:|
69
+ raise NotImplementedError, "#{self.class.name}##{__method__} is an abstract method."
70
+ end
71
+ end
72
+
73
+ def deployable?; true; end
74
+
75
+ protected
76
+
77
+ def setup_deploy_tasks
78
+ task :deploy do
79
+ desc "Run deployment"
80
+ action! :deploy
81
+ end
82
+ end
83
+ end
84
+
85
+ module Control
86
+ %i(start_process stop_process restart_process
87
+ show_process_status test_process
88
+ monitor_process unmonitor_process).each do |action|
89
+ define_method(action) do |args:, opts:|
90
+ raise NotImplementedError, "#{self.class.name}##{__method__} is an abstract method."
91
+ end
92
+ end
93
+
94
+ def controllable?; true; end
95
+
96
+ protected
97
+
98
+ def setup_control_tasks
99
+ task :start do
100
+ desc "Start process"
101
+ action! :start_process
102
+ end
103
+
104
+ task :stop do
105
+ desc "Stop process"
106
+ action! :stop_process
107
+ end
108
+
109
+ task :restart do
110
+ desc "Restart process"
111
+ action! :restart_process
112
+ end
113
+
114
+ task :status do
115
+ desc "Show process status"
116
+ action! :show_process_status
117
+ end
118
+
119
+ task :test do
120
+ desc "Test process"
121
+ action! :test_process
122
+ end
123
+
124
+ task :monitor do
125
+ desc "Turn on process monitor"
126
+ action! :monitor_process
127
+ end
128
+
129
+ task :unmonitor do
130
+ desc "Turn off process monitor"
131
+ action! :unmonitor_process
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ using Luban::CLI::CoreRefinements
138
+ include Luban::Deployment::Helpers::Configuration
139
+ include Luban::Deployment::Parameters::General
140
+
141
+ def display_name; @display_name ||= name.camelcase; end
142
+
143
+ def installable?; false; end
144
+ def deployable?; false; end
145
+ def controllable?; false; end
146
+
147
+ def task(cmd, **opts, &blk)
148
+ command(cmd, **opts, &blk).tap do |c|
149
+ add_common_task_options(c)
150
+ if !c.summary.nil? and c.description.empty?
151
+ c.long_desc "#{c.summary} in #{self.class.name}"
152
+ end
153
+ end
154
+ end
155
+
156
+ alias_method :undef_task, :undef_command
157
+
158
+ class << self
159
+ def default_worker_class
160
+ Luban::Deployment::Worker::Base
161
+ end
162
+
163
+ def worker_class(worker)
164
+ class_name = worker.camelcase
165
+ if const_defined?(class_name)
166
+ const_get(class_name)
167
+ else
168
+ abort "Aborted! #{name}::#{class_name} is NOT defined."
169
+ end
170
+ end
171
+
172
+ def define_task_method(method, task: method, worker:, locally: false, &blk)
173
+ define_method(method) do |args:, opts:|
174
+ run_task(cmd: task, args: args, opts: opts, locally: locally,
175
+ worker_class: self.class.worker_class(worker), &blk)
176
+ end
177
+ end
178
+ end
179
+
180
+ def run_task(cmd: nil, args:, opts:, locally: false,
181
+ worker_class: self.class.default_worker_class, &blk)
182
+ backtrace = opts.delete(:backtrace)
183
+ task_args = compose_task_arguments(args)
184
+ task_opts = compose_task_options(opts)
185
+ run_opts = extract_run_options(task_opts)
186
+ run_opts[:hosts] = :local if locally
187
+ task_msg = { cmd: cmd, config: config, local: locally,
188
+ args: task_args, opts: task_opts}
189
+ result = []
190
+ mutex = Mutex.new
191
+ run(**run_opts) do |backend|
192
+ begin
193
+ r = worker_class.new(task_msg.merge(backend: backend), &blk).run
194
+ rescue StandardError => e
195
+ r = {
196
+ status: :failed,
197
+ message: backtrace ? "#{e.message}\n#{e.backtrace.join("\n")}" : e.message,
198
+ error: e
199
+ }
200
+ end
201
+ mutex.synchronize { result << r }
202
+ end
203
+ print_task_result result
204
+ locally ? result.first[:__return__] : result
205
+ end
206
+
207
+ protected
208
+
209
+ def on_configure
210
+ super
211
+ set_parameters
212
+ set_default_parameters
213
+ load_configuration
214
+ validate_parameters
215
+ load_libraries
216
+ setup_cli
217
+ end
218
+
219
+ def set_parameters
220
+ copy_parameters_from_parent(
221
+ :luban_roles, :luban_root_path,
222
+ :stages, :production_stages, :applications,
223
+ :work_dir, :apps_path, :user
224
+ )
225
+ end
226
+
227
+ def copy_parameters_from_parent(*parameters)
228
+ parameters.each do |p|
229
+ if parent.respond_to?(p)
230
+ send(p, parent.send(p))
231
+ else
232
+ abort "Aborted! #{self.class.name} failed to copy parameter #{p.inspect} from #{parent.class.name}."
233
+ end
234
+ end
235
+ end
236
+
237
+ def set_default_parameters
238
+ set_default_general_parameters
239
+ end
240
+
241
+ def load_configuration; end
242
+
243
+ def validate_parameters
244
+ validate_general_parameters
245
+ end
246
+
247
+ def load_libraries; end
248
+
249
+ def setup_cli
250
+ setup_descriptions
251
+ setup_tasks
252
+ end
253
+
254
+ def setup_descriptions; end
255
+
256
+ def setup_tasks
257
+ setup_install_tasks if installable?
258
+ setup_deploy_tasks if deployable?
259
+ setup_control_tasks if controllable?
260
+ end
261
+
262
+ %i(install deploy control).each do |operation|
263
+ define_method("setup_#{operation}_tasks") do
264
+ raise NotImplementedError, "#{self.class.name}##{__method__} is an abstract method."
265
+ end
266
+ end
267
+
268
+ def add_common_task_options(task)
269
+ task.switch :dry_run, "Run as a simulation", short: :d
270
+ task.switch :once, "Run ONLY once", short: :o
271
+ task.option :roles, "Run with given roles",
272
+ type: :symbol, multiple: true, default: luban_roles
273
+ task.option :hosts, "Run with given hosts", multiple: true, default: []
274
+ task.option :in, "Run in parallel, sequence or group", short: :i,
275
+ type: :symbol, within: [:parallel, :sequence, :groups], default: :parallel
276
+ task.option :wait, "Wait interval for every run in sequence or groups", short: :w,
277
+ type: :integer, assure: ->(v){ v > 0 }, default: 2
278
+ task.option :limit, "Number of hosts per group", short: :n,
279
+ type: :integer, assure: ->(v){ v > 0 }, default: 2
280
+ task.option :format, "Set output format", short: :F,
281
+ type: :symbol, within: %i(pretty dot simpletext blackhole airbrussh),
282
+ default: :blackhole
283
+ task.option :verbosity, "Set verbosity level", short: :V,
284
+ type: :symbol, within: Luban::Deployment::Helpers::Utils::LogLevels,
285
+ default: :info
286
+ task.switch :backtrace, "Enable backtrace for exceptions", short: :B
287
+ end
288
+
289
+ def extract_run_options(task_opts)
290
+ %i(once roles hosts in wait limit
291
+ dry_run format verbosity).inject({}) do |opts, n|
292
+ opts[n] = task_opts.delete(n) if task_opts.has_key?(n)
293
+ opts
294
+ end
295
+ end
296
+
297
+ def compose_task_arguments(args); args.clone; end
298
+ def compose_task_options(opts); opts.clone; end
299
+
300
+ def run(roles: luban_roles, hosts: nil, once: false,
301
+ dry_run: false, format: log_format, verbosity: log_level, **opts)
302
+ configure_backend(dry_run: dry_run, format: format, verbosity: verbosity)
303
+ hosts = Array(hosts)
304
+ servers = select_servers(roles, hosts)
305
+ servers = servers.first if once and !servers.empty?
306
+ on(servers, **opts) { |backend| yield backend }
307
+ end
308
+
309
+ def select_servers(roles, hosts)
310
+ hosts.empty? ? release_roles(*roles) : hosts
311
+ end
312
+
313
+ def on(hosts, **opts, &blk)
314
+ SSHKit::Coordinator.new(hosts).each(opts) { blk.call(self) }
315
+ end
316
+
317
+ def print_task_result(result)
318
+ result.each do |entry|
319
+ next if entry[:message].to_s.empty?
320
+ puts " [#{entry[:hostname]}] #{entry[:message]}"
321
+ end
322
+ end
323
+
324
+ def backend_configured?; @@backend_configured ||= false; end
325
+
326
+ def configure_backend(dry_run:, format:, verbosity:)
327
+ return if backend_configured?
328
+ enable_dry_run if dry_run
329
+
330
+ SSHKit.configure do |sshkit|
331
+ sshkit.format = format
332
+ sshkit.output_verbosity = verbosity
333
+ sshkit.default_env = default_env
334
+ sshkit.backend = sshkit_backend
335
+ sshkit.backend.configure do |backend|
336
+ backend.pty = pty
337
+ backend.connection_timeout = connection_timeout
338
+ backend.ssh_options =
339
+ backend.ssh_options.merge(user: user).merge!(ssh_options)
340
+ end
341
+ end
342
+
343
+ configure_airbrussh if format == :airbrussh
344
+ @@backend_configured = true
345
+ end
346
+
347
+ def configure_airbrussh
348
+ require 'airbrussh'
349
+ Airbrussh.configure do |config|
350
+ config.command_output = [:stdout, :stderr]
351
+ end
352
+ SSHKit.config.output = Airbrussh::Formatter.new($stdout)
353
+ end
354
+
355
+ def enable_dry_run
356
+ sshkit_backend SSHKit::Backend::Printer
357
+ end
358
+ end
359
+ end
360
+ end