tomo 0.1.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 (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