luban 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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