tomo 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (135) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +221 -0
  4. data/exe/tomo +4 -0
  5. data/lib/tomo/cli/command.rb +36 -0
  6. data/lib/tomo/cli/common_options.rb +48 -0
  7. data/lib/tomo/cli/completions.rb +70 -0
  8. data/lib/tomo/cli/deploy_options.rb +59 -0
  9. data/lib/tomo/cli/error.rb +16 -0
  10. data/lib/tomo/cli/interrupted_error.rb +9 -0
  11. data/lib/tomo/cli/options.rb +38 -0
  12. data/lib/tomo/cli/parser.rb +92 -0
  13. data/lib/tomo/cli/project_options.rb +47 -0
  14. data/lib/tomo/cli/rules/argument.rb +42 -0
  15. data/lib/tomo/cli/rules/switch.rb +43 -0
  16. data/lib/tomo/cli/rules/value_switch.rb +58 -0
  17. data/lib/tomo/cli/rules.rb +98 -0
  18. data/lib/tomo/cli/rules_evaluator.rb +71 -0
  19. data/lib/tomo/cli/state.rb +29 -0
  20. data/lib/tomo/cli/usage.rb +42 -0
  21. data/lib/tomo/cli.rb +75 -0
  22. data/lib/tomo/colors.rb +46 -0
  23. data/lib/tomo/commands/completion_script.rb +46 -0
  24. data/lib/tomo/commands/default.rb +72 -0
  25. data/lib/tomo/commands/deploy.rb +67 -0
  26. data/lib/tomo/commands/help.rb +9 -0
  27. data/lib/tomo/commands/init.rb +92 -0
  28. data/lib/tomo/commands/run.rb +76 -0
  29. data/lib/tomo/commands/setup.rb +54 -0
  30. data/lib/tomo/commands/tasks.rb +32 -0
  31. data/lib/tomo/commands/version.rb +23 -0
  32. data/lib/tomo/commands.rb +13 -0
  33. data/lib/tomo/configuration/dsl/batch_block.rb +17 -0
  34. data/lib/tomo/configuration/dsl/config_file.rb +39 -0
  35. data/lib/tomo/configuration/dsl/environment_block.rb +13 -0
  36. data/lib/tomo/configuration/dsl/error_formatter.rb +75 -0
  37. data/lib/tomo/configuration/dsl/hosts_and_settings.rb +24 -0
  38. data/lib/tomo/configuration/dsl/tasks_block.rb +24 -0
  39. data/lib/tomo/configuration/dsl.rb +12 -0
  40. data/lib/tomo/configuration/environment.rb +12 -0
  41. data/lib/tomo/configuration/glob.rb +26 -0
  42. data/lib/tomo/configuration/plugin_file_not_found_error.rb +14 -0
  43. data/lib/tomo/configuration/plugin_resolver.rb +63 -0
  44. data/lib/tomo/configuration/plugins_registry/file_resolver.rb +43 -0
  45. data/lib/tomo/configuration/plugins_registry/gem_resolver.rb +63 -0
  46. data/lib/tomo/configuration/plugins_registry.rb +67 -0
  47. data/lib/tomo/configuration/project_not_found_error.rb +28 -0
  48. data/lib/tomo/configuration/role_based_task_filter.rb +42 -0
  49. data/lib/tomo/configuration/unknown_environment_error.rb +46 -0
  50. data/lib/tomo/configuration/unknown_plugin_error.rb +28 -0
  51. data/lib/tomo/configuration/unspecified_environment_error.rb +28 -0
  52. data/lib/tomo/configuration.rb +124 -0
  53. data/lib/tomo/console/key_reader.rb +51 -0
  54. data/lib/tomo/console/menu.rb +109 -0
  55. data/lib/tomo/console.rb +33 -0
  56. data/lib/tomo/error/suggestions.rb +44 -0
  57. data/lib/tomo/error.rb +22 -0
  58. data/lib/tomo/host.rb +57 -0
  59. data/lib/tomo/logger/tagged_io.rb +38 -0
  60. data/lib/tomo/logger.rb +70 -0
  61. data/lib/tomo/path.rb +19 -0
  62. data/lib/tomo/paths.rb +36 -0
  63. data/lib/tomo/plugin/bundler/helpers.rb +14 -0
  64. data/lib/tomo/plugin/bundler/tasks.rb +57 -0
  65. data/lib/tomo/plugin/bundler.rb +17 -0
  66. data/lib/tomo/plugin/core/helpers.rb +65 -0
  67. data/lib/tomo/plugin/core/tasks.rb +138 -0
  68. data/lib/tomo/plugin/core.rb +31 -0
  69. data/lib/tomo/plugin/env/tasks.rb +113 -0
  70. data/lib/tomo/plugin/env.rb +13 -0
  71. data/lib/tomo/plugin/git/helpers.rb +11 -0
  72. data/lib/tomo/plugin/git/tasks.rb +78 -0
  73. data/lib/tomo/plugin/git.rb +19 -0
  74. data/lib/tomo/plugin/nvm/tasks.rb +61 -0
  75. data/lib/tomo/plugin/nvm.rb +14 -0
  76. data/lib/tomo/plugin/puma/tasks.rb +38 -0
  77. data/lib/tomo/plugin/puma.rb +12 -0
  78. data/lib/tomo/plugin/rails/helpers.rb +20 -0
  79. data/lib/tomo/plugin/rails/tasks.rb +79 -0
  80. data/lib/tomo/plugin/rails.rb +11 -0
  81. data/lib/tomo/plugin/rbenv/tasks.rb +55 -0
  82. data/lib/tomo/plugin/rbenv.rb +12 -0
  83. data/lib/tomo/plugin/testing.rb +16 -0
  84. data/lib/tomo/plugin.rb +4 -0
  85. data/lib/tomo/plugin_dsl.rb +23 -0
  86. data/lib/tomo/remote.rb +55 -0
  87. data/lib/tomo/result.rb +28 -0
  88. data/lib/tomo/runtime/concurrent_ruby_load_error.rb +26 -0
  89. data/lib/tomo/runtime/concurrent_ruby_thread_pool.rb +50 -0
  90. data/lib/tomo/runtime/context.rb +21 -0
  91. data/lib/tomo/runtime/current.rb +41 -0
  92. data/lib/tomo/runtime/execution_plan.rb +107 -0
  93. data/lib/tomo/runtime/host_execution_step.rb +49 -0
  94. data/lib/tomo/runtime/inline_thread_pool.rb +27 -0
  95. data/lib/tomo/runtime/privileged_task.rb +6 -0
  96. data/lib/tomo/runtime/settings_interpolation.rb +55 -0
  97. data/lib/tomo/runtime/settings_required_error.rb +33 -0
  98. data/lib/tomo/runtime/task_aborted_error.rb +15 -0
  99. data/lib/tomo/runtime/task_runner.rb +56 -0
  100. data/lib/tomo/runtime/unknown_task_error.rb +23 -0
  101. data/lib/tomo/runtime.rb +82 -0
  102. data/lib/tomo/script.rb +44 -0
  103. data/lib/tomo/shell_builder.rb +108 -0
  104. data/lib/tomo/ssh/child_process.rb +64 -0
  105. data/lib/tomo/ssh/connection.rb +82 -0
  106. data/lib/tomo/ssh/connection_error.rb +16 -0
  107. data/lib/tomo/ssh/connection_validator.rb +87 -0
  108. data/lib/tomo/ssh/error.rb +11 -0
  109. data/lib/tomo/ssh/executable_error.rb +21 -0
  110. data/lib/tomo/ssh/options.rb +67 -0
  111. data/lib/tomo/ssh/permission_error.rb +18 -0
  112. data/lib/tomo/ssh/script_error.rb +23 -0
  113. data/lib/tomo/ssh/unknown_error.rb +13 -0
  114. data/lib/tomo/ssh/unsupported_version_error.rb +15 -0
  115. data/lib/tomo/ssh.rb +36 -0
  116. data/lib/tomo/task_library.rb +51 -0
  117. data/lib/tomo/templates/config.rb.erb +66 -0
  118. data/lib/tomo/testing/Dockerfile +10 -0
  119. data/lib/tomo/testing/connection.rb +34 -0
  120. data/lib/tomo/testing/docker_image.rb +115 -0
  121. data/lib/tomo/testing/docker_plugin_tester.rb +39 -0
  122. data/lib/tomo/testing/host_extensions.rb +27 -0
  123. data/lib/tomo/testing/local.rb +75 -0
  124. data/lib/tomo/testing/mock_plugin_tester.rb +26 -0
  125. data/lib/tomo/testing/mocked_exec_error.rb +6 -0
  126. data/lib/tomo/testing/plugin_tester.rb +49 -0
  127. data/lib/tomo/testing/remote_extensions.rb +10 -0
  128. data/lib/tomo/testing/ssh_extensions.rb +13 -0
  129. data/lib/tomo/testing/tomo_test_ed25519 +7 -0
  130. data/lib/tomo/testing/tomo_test_ed25519.pub +1 -0
  131. data/lib/tomo/testing/ubuntu_setup.sh +33 -0
  132. data/lib/tomo/testing.rb +39 -0
  133. data/lib/tomo/version.rb +3 -0
  134. data/lib/tomo.rb +45 -0
  135. metadata +308 -0
@@ -0,0 +1,107 @@
1
+ require "forwardable"
2
+
3
+ module Tomo
4
+ class Runtime
5
+ class ExecutionPlan
6
+ extend Forwardable
7
+
8
+ def_delegators :@task_runner, :paths, :settings
9
+
10
+ attr_reader :applicable_hosts
11
+
12
+ def initialize(tasks:, hosts:, task_filter:, task_runner:)
13
+ @hosts = hosts
14
+ @tasks = tasks
15
+ @task_runner = task_runner
16
+ @plan = build_plan(task_filter)
17
+ @applicable_hosts = gather_applicable_hosts
18
+ @thread_pool = build_thread_pool
19
+ freeze
20
+ validate_tasks!
21
+ end
22
+
23
+ def applicable_hosts_sentence
24
+ return "no hosts" if applicable_hosts.empty?
25
+
26
+ case applicable_hosts.length
27
+ when 1 then applicable_hosts.first.to_s
28
+ when 2 then applicable_hosts.map(&:to_s).join(" and ")
29
+ else
30
+ "#{applicable_hosts.first} and "\
31
+ "#{applicable_hosts.length - 1} other hosts"
32
+ end
33
+ end
34
+
35
+ def execute
36
+ open_connections do |remotes|
37
+ plan.each do |steps|
38
+ steps.each do |step|
39
+ step.execute(thread_pool: thread_pool, remotes: remotes)
40
+ end
41
+ thread_pool.run_to_completion
42
+ end
43
+ end
44
+ self
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :tasks, :hosts, :plan, :task_runner, :thread_pool
50
+
51
+ def validate_tasks!
52
+ plan.each do |steps|
53
+ steps.each do |step|
54
+ step.applicable_tasks.each do |task|
55
+ task_runner.validate_task!(task)
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ def open_connections
62
+ remotes = applicable_hosts.each_with_object({}) do |host, opened|
63
+ thread_pool.post(host) do |thr_host|
64
+ opened[thr_host] = task_runner.connect(thr_host)
65
+ end
66
+ end
67
+ thread_pool.run_to_completion
68
+ yield(remotes)
69
+ ensure
70
+ (remotes || {}).values.each(&:close)
71
+ end
72
+
73
+ def build_plan(task_filter)
74
+ tasks.each_with_object([]) do |task, result|
75
+ steps = hosts.map do |host|
76
+ HostExecutionStep.new(
77
+ tasks: task, host: host,
78
+ task_filter: task_filter, task_runner: task_runner
79
+ )
80
+ end
81
+ steps.reject!(&:empty?)
82
+ result << steps unless steps.empty?
83
+ end
84
+ end
85
+
86
+ def gather_applicable_hosts
87
+ plan.each_with_object([]) do |steps, result|
88
+ steps.each do |step|
89
+ result.push(*step.applicable_hosts)
90
+ end
91
+ end.uniq
92
+ end
93
+
94
+ def build_thread_pool
95
+ if plan.map(&:length).max.to_i > 1
96
+ ConcurrentRubyThreadPool.new(concurrency)
97
+ else
98
+ InlineThreadPool.new
99
+ end
100
+ end
101
+
102
+ def concurrency
103
+ [settings[:concurrency].to_i, 1].max
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,49 @@
1
+ module Tomo
2
+ class Runtime
3
+ class HostExecutionStep
4
+ attr_reader :applicable_hosts, :applicable_tasks
5
+
6
+ def initialize(tasks:, host:, task_filter:, task_runner:)
7
+ tasks = Array(tasks).flatten
8
+ @host = host
9
+ @task_runner = task_runner
10
+ @applicable_tasks = task_filter.filter(tasks, host: @host).freeze
11
+ @applicable_hosts = compute_applicable_hosts
12
+ freeze
13
+ end
14
+
15
+ def empty?
16
+ applicable_tasks.empty?
17
+ end
18
+
19
+ def execute(thread_pool:, remotes:)
20
+ return if applicable_tasks.empty?
21
+
22
+ thread_pool.post do
23
+ applicable_tasks.each do |task|
24
+ break if thread_pool.failure?
25
+
26
+ task_host = task.is_a?(PrivilegedTask) ? host.as_privileged : host
27
+ remote = remotes[task_host]
28
+ task_runner.run(task: task, remote: remote)
29
+ end
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :host, :task_runner
36
+
37
+ def compute_applicable_hosts
38
+ priv_tasks, normal_tasks = applicable_tasks.partition do |task|
39
+ task.is_a?(PrivilegedTask)
40
+ end
41
+
42
+ hosts = []
43
+ hosts << host if normal_tasks.any?
44
+ hosts << host.as_privileged if priv_tasks.any?
45
+ hosts.uniq.freeze
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,27 @@
1
+ module Tomo
2
+ class Runtime
3
+ class InlineThreadPool
4
+ def post(*args)
5
+ return if failure?
6
+
7
+ yield(*args)
8
+ nil
9
+ rescue StandardError => e
10
+ self.failure = e
11
+ nil
12
+ end
13
+
14
+ def run_to_completion
15
+ raise failure if failure?
16
+ end
17
+
18
+ def failure?
19
+ !!failure
20
+ end
21
+
22
+ private
23
+
24
+ attr_accessor :failure
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,6 @@
1
+ module Tomo
2
+ class Runtime
3
+ module PrivilegedTask
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,55 @@
1
+ module Tomo
2
+ class Runtime
3
+ class SettingsInterpolation
4
+ def self.interpolate(settings)
5
+ new(settings).call
6
+ end
7
+
8
+ def initialize(settings)
9
+ @settings = symbolize(settings)
10
+ end
11
+
12
+ def call
13
+ hash = Hash[settings.keys.map { |name| [name, fetch(name)] }]
14
+ dump_settings(hash) if Tomo.debug?
15
+ hash
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :settings
21
+
22
+ def fetch(name, stack=[])
23
+ raise_circular_dependency_error(name, stack) if stack.include?(name)
24
+ value = settings.fetch(name)
25
+ return value unless value.is_a?(String)
26
+
27
+ value.gsub(/%<(\w+)>/) do
28
+ fetch(Regexp.last_match[1].to_sym, stack + [name])
29
+ end
30
+ end
31
+
32
+ def raise_circular_dependency_error(name, stack)
33
+ dependencies = [*stack, name].join(" -> ")
34
+ raise "Circular dependency detected in settings: #{dependencies}"
35
+ end
36
+
37
+ def symbolize(hash)
38
+ hash.each_with_object({}) do |(key, value), symbolized|
39
+ symbolized[key.to_sym] = value
40
+ end
41
+ end
42
+
43
+ def dump_settings(hash)
44
+ key_len = hash.keys.map(&:to_s).map(&:length).max
45
+ dump = "Settings: {\n"
46
+ hash.to_a.sort_by(&:first).each do |key, value|
47
+ justified_key = "#{key}:".ljust(key_len + 1)
48
+ dump << " #{justified_key} #{value.inspect},\n"
49
+ end
50
+ dump << "}"
51
+ Tomo.logger.debug(dump)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,33 @@
1
+ module Tomo
2
+ class Runtime
3
+ class SettingsRequiredError < Tomo::Error
4
+ attr_accessor :command_name, :settings, :task
5
+
6
+ def to_console
7
+ <<~ERROR
8
+ The #{yellow(task)} task requires #{settings_sentence}
9
+
10
+ Settings can be specified in #{blue(DEFAULT_CONFIG_PATH)}, or by running tomo
11
+ with the #{blue('-s')} option. For example:
12
+
13
+ #{blue("tomo -s #{settings.first}=foo")}
14
+
15
+ You can also use environment variables:
16
+
17
+ #{blue("TOMO_#{settings.first.upcase}=foo tomo #{command_name}")}
18
+ ERROR
19
+ end
20
+
21
+ private
22
+
23
+ def settings_sentence
24
+ if settings.length == 1
25
+ return "a value for the #{yellow(settings.first.to_s)} setting."
26
+ end
27
+
28
+ sentence = "values for these settings:\n\n "
29
+ sentence << settings.map { |s| yellow(s.to_s) }.join("\n ")
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,15 @@
1
+ module Tomo
2
+ class Runtime
3
+ class TaskAbortedError < Tomo::Error
4
+ attr_accessor :task, :host
5
+
6
+ def to_console
7
+ <<~ERROR
8
+ The #{yellow(task)} task failed on #{yellow(host)}.
9
+
10
+ #{red(message)}
11
+ ERROR
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,56 @@
1
+ module Tomo
2
+ class Runtime
3
+ class TaskRunner
4
+ extend Forwardable
5
+
6
+ def_delegators :@context, :paths, :settings
7
+ attr_reader :context
8
+
9
+ def initialize(plugins_registry:, settings:)
10
+ interpolated_settings = SettingsInterpolation.interpolate(
11
+ plugins_registry.settings.merge(settings)
12
+ )
13
+ @helper_modules = plugins_registry.helper_modules.freeze
14
+ @context = Context.new(interpolated_settings)
15
+ @tasks_by_name = plugins_registry.bind_tasks(context).freeze
16
+ freeze
17
+ end
18
+
19
+ def validate_task!(name)
20
+ return if tasks_by_name.key?(name)
21
+
22
+ UnknownTaskError.raise_with(
23
+ name,
24
+ unknown_task: name,
25
+ known_tasks: tasks_by_name.keys
26
+ )
27
+ end
28
+
29
+ def run(task:, remote:)
30
+ validate_task!(task)
31
+ Current.with(task: task, remote: remote) do
32
+ Tomo.logger.task_start(task)
33
+ tasks_by_name[task].call
34
+ end
35
+ end
36
+
37
+ def connect(host)
38
+ Current.with(host: host) do
39
+ conn = SSH.connect(host: host, options: SSH::Options.new(settings))
40
+ remote = Remote.new(conn, context, helper_modules)
41
+ return remote unless block_given?
42
+
43
+ begin
44
+ return yield(remote)
45
+ ensure
46
+ remote&.close if block_given?
47
+ end
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ attr_reader :helper_modules, :tasks_by_name
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,23 @@
1
+ module Tomo
2
+ class Runtime
3
+ class UnknownTaskError < Error
4
+ attr_accessor :unknown_task, :known_tasks
5
+
6
+ def to_console
7
+ error = <<~ERROR
8
+ #{yellow(unknown_task)} is not a recognized task.
9
+ To see a list of all available tasks, run #{blue('tomo tasks')}.
10
+ ERROR
11
+
12
+ # TODO: suggest "did you forget to add the <abc> plugin?"
13
+
14
+ sugg = Error::Suggestions.new(
15
+ dictionary: known_tasks,
16
+ word: unknown_task
17
+ )
18
+ error << sugg.to_console if sugg.any?
19
+ error
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,82 @@
1
+ require "time"
2
+
3
+ module Tomo
4
+ class Runtime
5
+ autoload :ConcurrentRubyLoadError, "tomo/runtime/concurrent_ruby_load_error"
6
+ autoload :ConcurrentRubyThreadPool,
7
+ "tomo/runtime/concurrent_ruby_thread_pool"
8
+ autoload :Context, "tomo/runtime/context"
9
+ autoload :Current, "tomo/runtime/current"
10
+ autoload :ExecutionPlan, "tomo/runtime/execution_plan"
11
+ autoload :HostExecutionStep, "tomo/runtime/host_execution_step"
12
+ autoload :InlineThreadPool, "tomo/runtime/inline_thread_pool"
13
+ autoload :PrivilegedTask, "tomo/runtime/privileged_task"
14
+ autoload :SettingsInterpolation, "tomo/runtime/settings_interpolation"
15
+ autoload :SettingsRequiredError, "tomo/runtime/settings_required_error"
16
+ autoload :TaskAbortedError, "tomo/runtime/task_aborted_error"
17
+ autoload :TaskRunner, "tomo/runtime/task_runner"
18
+ autoload :UnknownTaskError, "tomo/runtime/unknown_task_error"
19
+
20
+ attr_reader :tasks
21
+
22
+ def initialize(deploy_tasks:, setup_tasks:, hosts:, task_filter:,
23
+ settings:, plugins_registry:)
24
+ @deploy_tasks = deploy_tasks.freeze
25
+ @setup_tasks = setup_tasks.freeze
26
+ @hosts = hosts.freeze
27
+ @task_filter = task_filter.freeze
28
+ @settings = settings
29
+ @plugins_registry = plugins_registry
30
+ @tasks = plugins_registry.task_names
31
+ freeze
32
+ end
33
+
34
+ def deploy!
35
+ execution_plan_for(deploy_tasks, release: :new).execute
36
+ end
37
+
38
+ def setup!
39
+ execution_plan_for(setup_tasks, release: :tmp).execute
40
+ end
41
+
42
+ def run!(task, *args, privileged: false)
43
+ task = task.dup.extend(PrivilegedTask) if privileged
44
+ execution_plan_for([task], release: :current, args: args).execute
45
+ end
46
+
47
+ def execution_plan_for(tasks, release: :current, args: [])
48
+ ExecutionPlan.new(
49
+ tasks: tasks,
50
+ hosts: hosts,
51
+ task_filter: task_filter,
52
+ task_runner: new_task_runner(release, args)
53
+ )
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :deploy_tasks, :setup_tasks, :hosts, :task_filter, :settings,
59
+ :plugins_registry
60
+
61
+ def new_task_runner(release_type, args)
62
+ run_settings = { release_path: release_path_for(release_type) }
63
+ .merge(settings)
64
+ .merge(run_args: args)
65
+
66
+ TaskRunner.new(plugins_registry: plugins_registry, settings: run_settings)
67
+ end
68
+
69
+ def release_path_for(type)
70
+ start_time = Time.now
71
+ release = start_time.utc.strftime("%Y%m%d%H%M%S")
72
+
73
+ case type
74
+ when :current then "%<current_path>"
75
+ when :new then "%<releases_path>/#{release}"
76
+ when :tmp then "%<tmp_path>/#{release}"
77
+ else
78
+ raise ArgumentError, "release: must be :current, :new, or :tmp"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,44 @@
1
+ module Tomo
2
+ class Script
3
+ attr_reader :script
4
+
5
+ def initialize(script,
6
+ echo: true,
7
+ pty: false,
8
+ raise_on_error: true,
9
+ silent: false)
10
+ @script = script
11
+ @echo = echo
12
+ @pty = pty
13
+ @raise_on_error = raise_on_error
14
+ @silent = silent
15
+ freeze
16
+ end
17
+
18
+ def echo?
19
+ !!@echo
20
+ end
21
+
22
+ def echo_string
23
+ return nil unless echo?
24
+
25
+ @echo == true ? script : @echo
26
+ end
27
+
28
+ def pty?
29
+ !!@pty
30
+ end
31
+
32
+ def raise_on_error?
33
+ !!@raise_on_error
34
+ end
35
+
36
+ def silent?
37
+ !!@silent
38
+ end
39
+
40
+ def to_s
41
+ script
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,108 @@
1
+ require "shellwords"
2
+
3
+ module Tomo
4
+ class ShellBuilder
5
+ def self.raw(string)
6
+ string.define_singleton_method(:shellescape) { string }
7
+ string
8
+ end
9
+
10
+ def initialize
11
+ @env = {}
12
+ @chdir = []
13
+ @prefixes = []
14
+ @umask = nil
15
+ end
16
+
17
+ def chdir(dir)
18
+ @chdir << dir
19
+ yield
20
+ ensure
21
+ @chdir.pop
22
+ end
23
+
24
+ def env(hash)
25
+ orig_env = @env
26
+ @env = orig_env.merge(hash || {})
27
+ yield
28
+ ensure
29
+ @env = orig_env
30
+ end
31
+
32
+ def prepend(*command)
33
+ prefixes.unshift(*command)
34
+ yield
35
+ ensure
36
+ prefixes.shift(command.length)
37
+ end
38
+
39
+ def umask(mask)
40
+ orig_umask = @umask
41
+ @umask = mask
42
+ yield
43
+ ensure
44
+ @umask = orig_umask
45
+ end
46
+
47
+ def build(*command, default_chdir: nil)
48
+ if @chdir.empty? && default_chdir
49
+ return chdir(default_chdir) { build(*command) }
50
+ end
51
+
52
+ command_string = command_to_string(*command)
53
+ modifiers = [cd_chdir, unset_env, export_env, set_umask].compact.flatten
54
+ [*modifiers, command_string].join(" && ")
55
+ end
56
+
57
+ private
58
+
59
+ attr_reader :prefixes
60
+
61
+ def command_to_string(*command)
62
+ command_string = shell_join(*command)
63
+ return command_string if prefixes.empty?
64
+
65
+ "#{shell_join(*prefixes)} #{command_string}"
66
+ end
67
+
68
+ def shell_join(*command)
69
+ return command.first.to_s if command.length == 1
70
+
71
+ command.flatten.compact.map(&:to_s).map(&:shellescape).join(" ")
72
+ end
73
+
74
+ def cd_chdir
75
+ @chdir.map { |dir| "cd #{dir.to_s.shellescape}" }
76
+ end
77
+
78
+ def unset_env
79
+ unsets = @env.select { |_, value| value.nil? }
80
+ return if unsets.empty?
81
+
82
+ ["unset", *unsets.map { |entry| entry.first.to_s.shellescape }].join(" ")
83
+ end
84
+
85
+ def export_env
86
+ exports = @env.reject { |_, value| value.nil? }
87
+ return if exports.empty?
88
+
89
+ [
90
+ "export",
91
+ *exports.map do |key, value|
92
+ "#{key.to_s.shellescape}=#{value.to_s.shellescape}"
93
+ end
94
+ ].join(" ")
95
+ end
96
+
97
+ def set_umask
98
+ return if @umask.nil?
99
+
100
+ umask_value = if @umask.is_a?(Integer)
101
+ @umask.to_s(8).rjust(4, "0")
102
+ else
103
+ @umask
104
+ end
105
+ "umask #{umask_value.to_s.shellescape}"
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,64 @@
1
+ require "open3"
2
+ require "shellwords"
3
+
4
+ module Tomo
5
+ module SSH
6
+ class ChildProcess
7
+ def self.execute(*command, on_data: ->(data) {})
8
+ process = new(*command, on_data: on_data)
9
+ process.wait_for_exit
10
+ process.result
11
+ end
12
+
13
+ def initialize(*command, on_data:)
14
+ @command = *command
15
+ @on_data = on_data
16
+ @stdout_buffer = StringIO.new
17
+ @stderr_buffer = StringIO.new
18
+ Tomo.logger.debug command.map(&:shellescape).join(" ")
19
+ end
20
+
21
+ def wait_for_exit
22
+ Open3.popen3(*command) do |stdin, stdout, stderr, wait_thread|
23
+ stdin.close
24
+ stdout_thread = start_io_thread(stdout, stdout_buffer)
25
+ stderr_thread = start_io_thread(stderr, stderr_buffer)
26
+ stdout_thread.join
27
+ stderr_thread.join
28
+ @exit_status = wait_thread.value.exitstatus
29
+ end
30
+ end
31
+
32
+ def result
33
+ Result.new(
34
+ exit_status: exit_status,
35
+ stdout: stdout_buffer.string,
36
+ stderr: stderr_buffer.string
37
+ )
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :command, :exit_status, :on_data,
43
+ :stdout_buffer, :stderr_buffer
44
+
45
+ def start_io_thread(source, buffer)
46
+ new_thread_inheriting_current_vars do
47
+ begin
48
+ while (line = source.gets)
49
+ on_data&.call(line)
50
+ buffer << line
51
+ end
52
+ rescue IOError # rubocop:disable Lint/HandleExceptions
53
+ end
54
+ end
55
+ end
56
+
57
+ def new_thread_inheriting_current_vars(&block)
58
+ Thread.new(Runtime::Current.variables) do |vars|
59
+ Runtime::Current.with(vars, &block)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end