loom-core 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +1 -0
  3. data/.rspec +2 -0
  4. data/Gemfile +4 -0
  5. data/Gemfile.lock +99 -0
  6. data/Guardfile +54 -0
  7. data/Rakefile +6 -0
  8. data/bin/loom +185 -0
  9. data/lib/env/development.rb +1 -0
  10. data/lib/loom.rb +44 -0
  11. data/lib/loom/all.rb +20 -0
  12. data/lib/loom/config.rb +106 -0
  13. data/lib/loom/core_ext.rb +37 -0
  14. data/lib/loom/dsl.rb +60 -0
  15. data/lib/loom/facts.rb +13 -0
  16. data/lib/loom/facts/all.rb +2 -0
  17. data/lib/loom/facts/fact_file_provider.rb +86 -0
  18. data/lib/loom/facts/fact_set.rb +138 -0
  19. data/lib/loom/host_spec.rb +32 -0
  20. data/lib/loom/inventory.rb +124 -0
  21. data/lib/loom/logger.rb +141 -0
  22. data/lib/loom/method_signature.rb +174 -0
  23. data/lib/loom/mods.rb +4 -0
  24. data/lib/loom/mods/action_proxy.rb +105 -0
  25. data/lib/loom/mods/all.rb +3 -0
  26. data/lib/loom/mods/mod_loader.rb +80 -0
  27. data/lib/loom/mods/module.rb +113 -0
  28. data/lib/loom/pattern.rb +15 -0
  29. data/lib/loom/pattern/all.rb +7 -0
  30. data/lib/loom/pattern/definition_context.rb +74 -0
  31. data/lib/loom/pattern/dsl.rb +176 -0
  32. data/lib/loom/pattern/hook.rb +28 -0
  33. data/lib/loom/pattern/loader.rb +48 -0
  34. data/lib/loom/pattern/reference.rb +71 -0
  35. data/lib/loom/pattern/reference_set.rb +169 -0
  36. data/lib/loom/pattern/result_reporter.rb +77 -0
  37. data/lib/loom/runner.rb +209 -0
  38. data/lib/loom/shell.rb +12 -0
  39. data/lib/loom/shell/all.rb +10 -0
  40. data/lib/loom/shell/api.rb +48 -0
  41. data/lib/loom/shell/cmd_result.rb +33 -0
  42. data/lib/loom/shell/cmd_wrapper.rb +164 -0
  43. data/lib/loom/shell/core.rb +226 -0
  44. data/lib/loom/shell/harness_blob.rb +26 -0
  45. data/lib/loom/shell/harness_command_builder.rb +50 -0
  46. data/lib/loom/shell/session.rb +25 -0
  47. data/lib/loom/trap.rb +44 -0
  48. data/lib/loom/version.rb +3 -0
  49. data/lib/loomext/all.rb +4 -0
  50. data/lib/loomext/corefacts.rb +6 -0
  51. data/lib/loomext/corefacts/all.rb +8 -0
  52. data/lib/loomext/corefacts/facter_provider.rb +24 -0
  53. data/lib/loomext/coremods.rb +5 -0
  54. data/lib/loomext/coremods/all.rb +13 -0
  55. data/lib/loomext/coremods/exec.rb +50 -0
  56. data/lib/loomext/coremods/files.rb +104 -0
  57. data/lib/loomext/coremods/net.rb +33 -0
  58. data/lib/loomext/coremods/package/adapter.rb +100 -0
  59. data/lib/loomext/coremods/package/package.rb +62 -0
  60. data/lib/loomext/coremods/user.rb +82 -0
  61. data/lib/loomext/coremods/vm.rb +0 -0
  62. data/lib/loomext/coremods/vm/all.rb +6 -0
  63. data/lib/loomext/coremods/vm/vbox.rb +84 -0
  64. data/loom.gemspec +39 -0
  65. data/loom/inventory.yml +13 -0
  66. data/scripts/harness.sh +242 -0
  67. data/spec/loom/host_spec_spec.rb +101 -0
  68. data/spec/loom/inventory_spec.rb +154 -0
  69. data/spec/loom/method_signature_spec.rb +275 -0
  70. data/spec/loom/pattern/dsl_spec.rb +207 -0
  71. data/spec/loom/shell/cmd_wrapper_spec.rb +239 -0
  72. data/spec/loom/shell/harness_blob_spec.rb +42 -0
  73. data/spec/loom/shell/harness_command_builder_spec.rb +36 -0
  74. data/spec/runloom.sh +35 -0
  75. data/spec/scripts/harness_spec.rb +385 -0
  76. data/spec/spec_helper.rb +94 -0
  77. data/spec/test.loom +370 -0
  78. data/spec/test_loom_spec.rb +57 -0
  79. metadata +287 -0
data/lib/loom/shell.rb ADDED
@@ -0,0 +1,12 @@
1
+ module Loom
2
+ module Shell
3
+
4
+ VerifyError = Class.new Loom::LoomError
5
+
6
+ def self.create(*args)
7
+ Loom::Shell::Core.new *args
8
+ end
9
+ end
10
+ end
11
+
12
+ require_relative "shell/all"
@@ -0,0 +1,10 @@
1
+ require_relative "harness_blob"
2
+ require_relative "harness_command_builder"
3
+
4
+ require_relative "cmd_result"
5
+ require_relative "cmd_wrapper"
6
+
7
+ require_relative "session"
8
+ require_relative "api"
9
+
10
+ require_relative "core"
@@ -0,0 +1,48 @@
1
+ module Loom::Shell
2
+
3
+ ##
4
+ # A facade for the shell API exposed to Loom files. This is the +loom+ object
5
+ # passed to patterns.
6
+ class Api
7
+
8
+ def initialize(shell)
9
+ @shell = shell
10
+ @mod_loader = shell.mod_loader
11
+ @dry_run = shell.dry_run
12
+ end
13
+
14
+ def dry_run?
15
+ @dry_run
16
+ end
17
+
18
+ def local
19
+ @shell.local.shell_api
20
+ end
21
+
22
+ def method_missing(name, *args, &block)
23
+ Loom.log.debug3(self) { "shell api => #{name} #{args} #{block}" }
24
+ # TODO: The relationship between shell and mod_loader seems leaky here, a
25
+ # Shell::Api should have a shell and not care about the mod_loader,
26
+ # currently it seems to violate Demeter. The shell should dispatch to the
27
+ # mod_loader only as an implementation detail. Otherwise this is harder to
28
+ # test.
29
+ @mod_loader.send name, @shell, *args, &block
30
+ end
31
+ end
32
+
33
+ class FakeApi < Api
34
+
35
+ # Fake Override
36
+ def initialize
37
+ @cmd_executions = []
38
+ @cmd_execution_args = []
39
+ end
40
+ attr_reader :cmd_executions, :cmd_execution_args
41
+
42
+ def method_missing(name, *args, &block)
43
+ @cmd_executions.push name
44
+ @cmd_execution_args.push args
45
+ end
46
+ end
47
+
48
+ end
@@ -0,0 +1,33 @@
1
+ module Loom::Shell
2
+ class CmdResult
3
+ def initialize(command, stdout, stderr, exit_status, is_test, shell)
4
+ @command = command
5
+ @stdout = stdout
6
+ @stderr = stderr
7
+ @exit_status = exit_status
8
+ @is_test = is_test
9
+ @time = Time.now
10
+ @shell = shell
11
+ end
12
+
13
+ attr_reader :command, :stdout, :stderr, :exit_status, :time, :is_test
14
+
15
+ def success?
16
+ @exit_status == 0
17
+ end
18
+
19
+ def pipe(*cmd, fd: :stdout)
20
+ puts "stdout >>> " + @stdout.inspect
21
+ @shell.pipe [:"/bin/echo", "-e", @stdout], [*cmd]
22
+ end
23
+
24
+ def self.create_from_sshkit_command(cmd, is_test, shell)
25
+ CmdResult.new cmd.command,
26
+ cmd.full_stdout,
27
+ cmd.full_stderr,
28
+ cmd.exit_status,
29
+ is_test,
30
+ shell
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,164 @@
1
+ require "shellwords"
2
+
3
+ module Loom::Shell
4
+
5
+ class CmdWrapper
6
+
7
+ class << self
8
+ # Escapes a shell command.
9
+ # @param cmd [CmdWrapper|String]
10
+ # @return [String]
11
+ def escape(cmd)
12
+ if cmd.is_a? CmdWrapper
13
+ cmd.escape_cmd
14
+ else
15
+ Shellwords.escape(cmd)
16
+ end
17
+ end
18
+
19
+ # Wraps a command in another command. See `CmdWrapper.new'
20
+ # @param cmd_parts [CmdWrapper|String|Symbol]
21
+ # @param should_quote [Boolean]
22
+ def wrap_cmd(*cmd_parts, should_quote: false)
23
+ cmd_parts = cmd_parts.map do |parts|
24
+ if parts.respond_to? :cmd_parts
25
+ parts.cmd_parts
26
+ else
27
+ parts
28
+ end
29
+ end
30
+ CmdWrapper.new *cmd_parts.flatten, {
31
+ :should_quote => should_quote,
32
+ :is_wrapped => true
33
+ }
34
+ end
35
+ end
36
+
37
+ # @param cmd [Array<[#to_s]>] Command parts that will be shell escaped.
38
+ # @param :should_quote [Boolean] Whether wrapped commands should be quoted.
39
+ # @param :redirc [Array<CmdRedirect>] STDIO redirection for the command
40
+ # in quotes.
41
+ def initialize(*cmd, should_quote: false, is_wrapped: false, redirect: [])
42
+ @cmd_parts = cmd.flatten
43
+ @should_quote = should_quote
44
+ @is_wrapped = is_wrapped
45
+ @redirects = [redirect].flatten.compact
46
+ Loom.log.debug2(self) { "CmdWrapper.new {#{cmd}} => #{self.escape_cmd}" }
47
+ end
48
+
49
+ attr_reader :cmd_parts
50
+
51
+ # Shell escapes each part of `@cmd_parts` and joins them with spaces.
52
+ # @return [String]
53
+ def escape_cmd
54
+ escaped_cmd = escape_inner
55
+
56
+ cmd_with_redirects = [escaped_cmd].concat @redirects.map(&:to_s)
57
+ cmd_with_redirects.join " "
58
+ end
59
+ alias_method :to_s, :escape_cmd
60
+
61
+ # @param wrapped_cmd [String]
62
+ # @return [Array<#to_s>] The `wrapped_cmd` wrapped by `#escape_cmd`
63
+ def wrap(*wrapped_cmd)
64
+ wrapped_cmd =
65
+ CmdWrapper.wrap_cmd(*wrapped_cmd, should_quote: @should_quote)
66
+ CmdWrapper.new(self, wrapped_cmd)
67
+ end
68
+
69
+ private
70
+ def escape_inner
71
+ escaped_parts = escape_parts(@cmd_parts)
72
+
73
+ # Don't fuck with this unless you really want to fix it.
74
+ if @should_quote && @is_wrapped
75
+ double_escaped = escape_parts(escaped_parts).join " "
76
+
77
+ # Shellwords escapes spaces, but I'm wrapping this string in another set
78
+ # of quotes here, so it's unnecessary.
79
+ double_escaped.gsub!(/\\(\s)/, "\\1") while double_escaped.match(/\\\s/)
80
+
81
+ "\"#{double_escaped}\""
82
+ else
83
+ escaped_parts.join " "
84
+ end
85
+ end
86
+
87
+ # Maps each entry of #{cmd_parts} to the escaped form of itself, except if
88
+ # the part is frozen (like a Symbol)
89
+ # @param cmd_parts [Array<String|Symbol|CmdWrapper>]
90
+ # @return [Array<String|Symbol>]
91
+ def escape_parts(cmd_parts)
92
+ cmd_parts.map do |part|
93
+ part.cmd_parts rescue part
94
+ end.flatten
95
+
96
+ cmd_parts.map do |part|
97
+ unless part.frozen?
98
+ CmdWrapper.escape part
99
+ else
100
+ part
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ class CmdRedirect
107
+
108
+ class << self
109
+ def append_stdout(word)
110
+ CmdRedirect.new(word, mode: Mode::APPEND)
111
+ end
112
+ end
113
+
114
+ # See `man bash` under REDIRECTION
115
+ module Mode
116
+ INPUT = :input
117
+ OUTPUT = :output
118
+ APPEND = :append
119
+ OUTPUT_12 = :output_1_and_2
120
+ APPEND_12 = :append_1_and_2
121
+ end
122
+
123
+ def initialize(word, fd: nil, mode: Mode::OUTPUT)
124
+ @fd = fd
125
+ @word = word
126
+ @mode = mode
127
+ end
128
+
129
+ def to_s
130
+ case @mode
131
+ when Mode::INPUT
132
+ "%s<%s" % [@fd, @word]
133
+ when Mode::OUTPUT
134
+ "%s>%s" % [@fd, @word]
135
+ when Mode::APPEND
136
+ "%s>>%s" % [@fd, @word]
137
+ when Mode::OUTPUT_12
138
+ "&>%s" % [@word]
139
+ when Mode::APPEND_12
140
+ "&>>%s" % [@word]
141
+ else
142
+ raise "invalid shell redirection mode: #{@mode}"
143
+ end
144
+ end
145
+
146
+ end
147
+
148
+ class CmdPipeline
149
+ def initialize(piped_cmds)
150
+ @piped_cmds = piped_cmds
151
+ end
152
+
153
+ def to_s
154
+ @piped_cmds.map do |cmd|
155
+ if cmd.respond_to? :escape_cmd
156
+ cmd.escape_cmd
157
+ else
158
+ cmd
159
+ end
160
+ end.join " | "
161
+ end
162
+ end
163
+
164
+ end
@@ -0,0 +1,226 @@
1
+ require "forwardable"
2
+ require "shellwords"
3
+ require "sshkit"
4
+
5
+ module Loom::Shell
6
+
7
+ class Core
8
+
9
+ def initialize(mod_loader, sshkit_backend, dry_run=false)
10
+ @dry_run = dry_run
11
+ @mod_loader = mod_loader
12
+ @sshkit_backend = sshkit_backend
13
+
14
+ @session = Session.new
15
+ @shell_api = Api.new self
16
+
17
+ @cmd_wrappers = []
18
+ @sudo_users = []
19
+
20
+ # TODO: @sudo_dirs is a smelly workaround for not having a better
21
+ # understanding of sudo security policies and inheriting environments.
22
+ @sudo_dirs = []
23
+ end
24
+
25
+ attr_reader :session, :shell_api, :mod_loader, :dry_run
26
+
27
+ def local
28
+ @local ||= LocalShell.new @mod_loader, @session, @dry_run
29
+ end
30
+
31
+ def test(*cmd, check: :exit_status, **cmd_opts)
32
+ # TODO: is_test smells like a hack. I can't rely on Command#is_success?
33
+ # here (returned from execute) because I'm overriding it with :is_test =>
34
+ # true. Fix Command#is_success? to not be a lie.. that is a lazy hack for
35
+ # result reporting (I think the fix & feature) is to define Command
36
+ # objects and declare style of reporting & error code handling it
37
+ # has. Commands can be defined to ignore errors and just return their
38
+ # results.
39
+ execute *cmd, :is_test => true, **cmd_opts
40
+
41
+ case check
42
+ when :exit_status
43
+ @session.last.exit_status == 0
44
+ when :stderr
45
+ @session.last.stderr.empty?
46
+ else
47
+ raise "unknown test check => #{check}"
48
+ end
49
+ end
50
+
51
+ def verify(*check)
52
+ raise VerifyError, check unless test *check
53
+ end
54
+
55
+ def verify_which(command)
56
+ verify :which, command
57
+ end
58
+
59
+ def wrap(*wrapper, first: false, should_quote: true, &block)
60
+ raise "missing block for +wrap+" unless block_given?
61
+
62
+ cmd_wrapper = CmdWrapper.new(*wrapper, should_quote: should_quote)
63
+
64
+ if first
65
+ @cmd_wrappers.unshift(cmd_wrapper)
66
+ else
67
+ @cmd_wrappers.push(cmd_wrapper)
68
+ end
69
+
70
+ begin
71
+ yield
72
+ ensure
73
+ first ? @cmd_wrappers.shift : @cmd_wrappers.pop
74
+ end
75
+ end
76
+
77
+ def sudo(user=nil, *sudo_cmd, &block)
78
+ user ||= :root
79
+ Loom.log.debug1(self) { "sudo => #{user} #{sudo_cmd} #{block}" }
80
+
81
+ is_new_sudoer = @sudo_users.last.to_sym != user.to_sym rescue true
82
+
83
+ @sudo_dirs.push(capture :pwd)
84
+ @sudo_users.push << user if is_new_sudoer
85
+
86
+ sudo_wrapper = [:sudo, "-u", user, "--", "/bin/sh", "-c"]
87
+ sudo_cmd.compact!
88
+ begin
89
+ wrap *sudo_wrapper, :should_quote => true do
90
+ execute *sudo_cmd unless sudo_cmd.empty?
91
+ yield if block_given?
92
+ end
93
+ ensure
94
+ @sudo_users.pop if is_new_sudoer
95
+ @sudo_dirs.pop
96
+ end
97
+ end
98
+
99
+ def cd(path, &block)
100
+ Loom.log.debug1(self) { "cd => #{path} #{block}" }
101
+
102
+ # TODO: this might creates problems with relative paths, e.g.
103
+ # loom.cd foo => cd ./foo
104
+ # loom.sudo user => cd ./foo; sudo user
105
+ @sudo_dirs.push path
106
+ begin
107
+ @sshkit_backend.within path, &block
108
+ ensure
109
+ @sudo_dirs.pop
110
+ end
111
+ end
112
+
113
+ def capture(*cmd_parts)
114
+ if @dry_run
115
+ # TODO: I'm not sure what to do about this.
116
+ Loom.log.warn "`capture` during dry run won't do what you want"
117
+ end
118
+ execute *cmd_parts
119
+ @session.last.stdout.strip
120
+ end
121
+
122
+ def pipe(*cmds)
123
+ cmd = CmdWrapper.pipe *cmds.map { |*cmd| CmdWrapper.new *cmd }
124
+ execute cmd
125
+ end
126
+
127
+ def execute(*cmd_parts, is_test: false, **cmd_opts)
128
+ cmd_parts.compact!
129
+ raise "empty command passed to execute" if cmd_parts.empty?
130
+
131
+ result = if @dry_run
132
+ wrap :printf, :first => true do
133
+ cmd_result = execute_internal *cmd_parts, **cmd_opts
134
+ Loom.log.info do
135
+ "\t%s" % prompt_fmt(cmd_result.full_stdout.strip)
136
+ end
137
+ cmd_result
138
+ end
139
+ else
140
+ execute_internal *cmd_parts, **cmd_opts
141
+ end
142
+ @session << CmdResult.create_from_sshkit_command(result, is_test, self)
143
+
144
+ Loom.log.debug @session.last.stdout unless @session.last.stdout.empty?
145
+ Loom.log.debug @session.last.stderr unless @session.last.stderr.empty?
146
+ @session.last
147
+ end
148
+ alias_method :exec, :execute
149
+
150
+ protected
151
+ def prompt_label
152
+ # TODO: get the real hostname.
153
+ "remote"
154
+ end
155
+
156
+ private
157
+ def prompt_fmt(*cmd_parts)
158
+ output = Shellwords.join(cmd_parts).gsub /\\/, ''
159
+ "[%s]:$ %s" % [prompt_label, output]
160
+ end
161
+
162
+ def execute_internal(*cmd_parts, piped_cmds: [])
163
+ primary_cmd = create_command *cmd_parts
164
+ piped_cmds = piped_cmds.map { |cmd_parts| CmdWrapper.new *cmd_parts }
165
+
166
+ cmd = CmdPipeline.new([primary_cmd].concat(piped_cmds)).to_s
167
+ # Tests if the command looks like "echo\ hi", the trailing slash after
168
+ # echo indicates that just 1 big string was passed in and we can't really
169
+ # isolate the execuatable part of the command. This might be fine, but
170
+ # it's better to be strict now and relax this later if it's OK.
171
+ if cmd.match /^[\w\-\[]+\\/i
172
+ raise "use array parts for command escaping => #{cmd}"
173
+ end
174
+
175
+ Loom.log.debug1(self) { "executing => #{cmd}" }
176
+
177
+ # This is a big hack to get access to the SSHKit command
178
+ # object and avoid the automatic errors thrown on non-zero
179
+ # error codes
180
+ @sshkit_backend.send(
181
+ :create_command_and_execute,
182
+ cmd,
183
+ :raise_on_non_zero_exit => false)
184
+ end
185
+
186
+ # Here be dragons.
187
+ # @return [String|Loom::Shell::CmdWrapper]
188
+ def create_command(*cmd_parts)
189
+ cmd_wrapper = if cmd_parts.is_a? CmdWrapper
190
+ cmd_parts
191
+ else
192
+ Loom.log.debug3(self) { "new cmd from parts => #{cmd_parts}" }
193
+ CmdWrapper.new *cmd_parts
194
+ end
195
+
196
+ # Useful for sudo, dry runs, timing a set of commands, or
197
+ # timeout... anytime you want to prefix a group of commands. Reverses the
198
+ # array to wrap from inner most call to `#{wrap}` to outer most.
199
+ cmd = @cmd_wrappers.reverse.reduce(cmd_wrapper) do |cmd_or_wrapper, wrapper|
200
+ Loom.log.debug3(self) { "wrapping cmds => #{wrapper} => #{cmd_or_wrapper}"}
201
+ wrapper.wrap cmd_or_wrapper
202
+ end
203
+
204
+ unless @sudo_dirs.empty? || @dry_run
205
+ cmd = "cd #{@sudo_dirs.last}; " << cmd.to_s
206
+ end
207
+ cmd
208
+ end
209
+
210
+ # A shell object restricted to localhost.
211
+ class LocalShell < Core
212
+ def initialize(mod_loader, session, dry_run)
213
+ super mod_loader, SSHKit::Backend::Local.new, dry_run
214
+ @session = session
215
+ end
216
+
217
+ def local
218
+ raise 'already in a local shell'
219
+ end
220
+
221
+ def prompt_label
222
+ "local"
223
+ end
224
+ end
225
+ end
226
+ end