cem_win_spec 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.
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require_relative 'base_exec'
5
+
6
+ module CemWinSpec
7
+ module WinExec
8
+ # Class for executing local shell commands
9
+ class LocalExec < BaseExec
10
+ attr_reader :thread_results
11
+
12
+ def initialize(working_dir = nil)
13
+ @ran_in_thread = false
14
+ @thread_results = {}
15
+ super(working_dir)
16
+ end
17
+
18
+ def available?
19
+ true
20
+ end
21
+
22
+ def ran_in_thread?
23
+ @ran_in_thread
24
+ end
25
+
26
+ def any_threads?
27
+ @ran_in_thread && thread_group.list.any?
28
+ end
29
+
30
+ def join_threads
31
+ thread_group.list.each(&:join)
32
+ end
33
+
34
+ # Spawn a new process and detach it from the current process
35
+ # This is useful for running commands that should not block the current process
36
+ # and that you don't need stdout/stderr from.
37
+ # @param cmd [String] The command to execute
38
+ def bg_exec(cmd, *_args, **_kwargs)
39
+ puts_cmd(cmd)
40
+ Process.detach(spawn(cd_working_dir(cmd)))
41
+ end
42
+
43
+ # Execute a command in a new thread. This works by creating a new thread in a thread group
44
+ # and then executing the command in that thread. The thread group is used to keep track of
45
+ # all the threads that have been created and to join / exit / kill them all at once. The
46
+ # command results are stored in a hash with the command as the key and the results as the
47
+ # value. The results are stored as an array of [stdout, stderr, status] where status is a
48
+ # Process::Status object.
49
+ # @param cmd [String] The command to execute
50
+ # @return [Array] An array of [:threaded, cmd]
51
+ def thread_exec(cmd, *_args, **_kwargs)
52
+ @ran_in_thread = true
53
+ th = Thread.new do
54
+ puts_cmd(cmd)
55
+ so, se, st = Open3.capture3(cd_working_dir(cmd))
56
+ @thread_results[cmd] = [so, se, st]
57
+ end
58
+ thread_group.add th
59
+ :threaded
60
+ end
61
+
62
+ def exec(cmd, *_args, **_kwargs)
63
+ puts_cmd(cmd)
64
+ Open3.caputure3(cd_working_dir(cmd))
65
+ end
66
+
67
+ private
68
+
69
+ def thread_group
70
+ @thread_group ||= ThreadGroup.new
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'winrm'
4
+ require_relative '../logging'
5
+
6
+ module CemWinSpec
7
+ module WinExec
8
+ # Wrapper class for WinRM::Output
9
+ class Output
10
+ include CemWinSpec::Logging
11
+
12
+ attr_reader :stdout, :stderr, :exitcode
13
+
14
+ def initialize(output, quiet: false, line_prefix: " #$ ")
15
+ @raw_output = output
16
+ @quiet = quiet
17
+ @line_prefix = line_prefix
18
+ @pending_threaded = nil
19
+ set_vars_from_output(output)
20
+ end
21
+
22
+ def nil?
23
+ @raw_output.nil?
24
+ end
25
+
26
+ def success?
27
+ @exitcode&.zero?
28
+ end
29
+
30
+ def pending_threaded?
31
+ !@pending_threaded.nil?
32
+ end
33
+
34
+ def puts_combined
35
+ puts_stream(:stdout)
36
+ puts_stream(:stderr)
37
+ end
38
+
39
+ def puts_stream(stream_name)
40
+ return if @quiet || @raw_output.nil?
41
+ raise "Invalid stream: #{stream_name}" unless %i[stdout stderr].include?(stream_name)
42
+
43
+ stream = send(stream_name)
44
+ return if stream.nil? || stream.empty?
45
+
46
+ out_array = stream.split(%r{\n|\r\n}).map do |c|
47
+ chomped_c = c&.chomp
48
+ if chomped_c.nil? || chomped_c.strip.empty?
49
+ nil
50
+ else
51
+ format_output_string(chomped_c)
52
+ end
53
+ end
54
+ out_array.compact!
55
+ return if out_array.empty?
56
+
57
+ logger.info "#{stream_name.to_s.upcase}:\n#{out_array.join("\n")}"
58
+ end
59
+
60
+ private
61
+
62
+ def format_output_string(str)
63
+ # Cut the number of spaces in half, replace tabs with double spaces
64
+ str.gsub!(/\s\s/, ' ')
65
+ str.gsub!(/\t/, ' ')
66
+ return "#{@line_prefix}#{str}" if str.length <= 115
67
+
68
+ str.scan(/.{1,115}/).map { |s| "#{@line_prefix}#{s}" }.join("\n")
69
+ end
70
+
71
+ def set_vars_from_output(out)
72
+ if out.is_a?(Array)
73
+ if out[0] == :threaded
74
+ @stdout = ''
75
+ @stderr = ''
76
+ @exitcode = 0
77
+ # Set pending threaded to the command we are waiting on
78
+ @pending_threaded = out[1]
79
+ elsif out.length == 3
80
+ @stdout = out[0]
81
+ @stderr = out[1]
82
+ @exitcode = out[2].to_i
83
+ else
84
+ raise "Invalid output array: #{out}"
85
+ end
86
+ elsif out.is_a? WinRM::Output
87
+ @stdout = out.stdout
88
+ @stderr = out.stderr
89
+ @exitcode = out.exitcode
90
+ elsif out.is_a? StandardError
91
+ @stdout = out.message
92
+ @stderr = out.backtrace.join("\n")
93
+ @exitcode = 1
94
+ elsif out.respond_to? :to_s
95
+ @stdout = out.to_s
96
+ @stderr = ''
97
+ @exitcode = 0
98
+ else
99
+ raise "Invalid output type: #{out.class}"
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'winrm'
4
+ require 'winrm-fs'
5
+ require_relative 'base_exec'
6
+
7
+ module CemWinSpec
8
+ module WinExec
9
+ # Class for executing PowerShell commands over WinRM
10
+ class WinRMExec < BaseExec
11
+ attr_reader :conn_opts
12
+
13
+ def initialize(conn_opts, working_dir = nil, quiet: false)
14
+ @conn_opts = conn_opts
15
+ @quiet = quiet
16
+ @conn = new_conn(@conn_opts.to_h)
17
+ super(working_dir)
18
+ end
19
+
20
+ def available?
21
+ @available
22
+ end
23
+
24
+ def exec(cmd, *_args, **_kwargs)
25
+ puts_cmd(cmd)
26
+ shell = nil
27
+ output = nil
28
+ begin
29
+ shell = conn.shell(:powershell)
30
+ output = shell.run(cd_working_dir(cmd))
31
+ rescue WinRM::WinRMAuthorizationError => e
32
+ @available = false
33
+ raise e
34
+ ensure
35
+ shell&.close
36
+ end
37
+ raise 'Something went wrong, no output from command' if output.nil?
38
+
39
+ output
40
+ end
41
+
42
+ def create_dir(path)
43
+ result = file_manager.create_dir(path)
44
+ raise "Failed to create directory #{path}" unless result
45
+
46
+ logger.info "Created directory #{path}"
47
+ result
48
+ end
49
+
50
+ def delete_file(path)
51
+ result = file_manager.delete(path)
52
+ raise "Failed to delete file #{path}" unless result
53
+
54
+ logger.info "Deleted file #{path}"
55
+ result
56
+ end
57
+
58
+ def file_exists?(path)
59
+ file_manager.exists?(path)
60
+ end
61
+
62
+ def temp_dir
63
+ file_manager.temp_dir
64
+ end
65
+
66
+ def upload(local_path, remote_path)
67
+ file_manager.upload(local_path, remote_path) do |bytes_copied, total_bytes, lpath, rpath|
68
+ logger.debug "Copied #{bytes_copied} of #{total_bytes} bytes from #{lpath} to #{rpath}"
69
+ end
70
+ logger.info "Uploaded #{local_path} to #{remote_path}"
71
+ end
72
+
73
+ private
74
+
75
+ attr_reader :conn
76
+
77
+ def new_conn(hash_opts)
78
+ logger.debug "Creating connection to #{hash_opts[:endpoint]}"
79
+ new_conn = WinRM::Connection.new(hash_opts)
80
+ new_conn.logger = logger
81
+ new_conn
82
+ end
83
+
84
+ def file_manager
85
+ @file_manager ||= WinRM::FS::FileManager.new(conn)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require_relative 'iap_tunnel'
5
+ require_relative 'logging'
6
+ require_relative 'module_archive_builder'
7
+ require_relative 'win_exec/connection_opts'
8
+ require_relative 'win_exec/local_exec'
9
+ require_relative 'win_exec/winrm_exec'
10
+ require_relative 'win_exec/output'
11
+
12
+ module CemWinSpec
13
+ module WinExec
14
+ # Class for executing PowerShell commands over WinRM
15
+ class Exec
16
+ include CemWinSpec::Logging
17
+
18
+ attr_reader :title, :result, :reuse_tunnel, :ignore_exitcode
19
+
20
+ def initialize(title, local_exec, winrm_exec, iap_tunnel, ma_builder, rspec_test_cmds, &block)
21
+ @title = title
22
+ @local_exec = local_exec
23
+ @winrm_exec = winrm_exec
24
+ @iap_tunnel = iap_tunnel
25
+ @ma_builder = ma_builder
26
+ @rspec_test_cmds = rspec_test_cmds
27
+ @block = block
28
+ @reuse_tunnel = true
29
+ @ignore_exitcode = false
30
+ @spinner = nil
31
+ @result = nil
32
+ end
33
+
34
+ def reuse_tunnel=(value)
35
+ raise ArgumentError, 'reuse_tunnel must be a boolean' unless [true, false].include?(value)
36
+
37
+ @reuse_tunnel = value
38
+ end
39
+ alias reuse_tunnel? reuse_tunnel
40
+
41
+ def ignore_exitcode=(value)
42
+ raise ArgumentError, 'ignore_exitcode must be a boolean' unless [true, false].include?(value)
43
+
44
+ @ignore_exitcode = value
45
+ end
46
+ alias ignore_exitcode? ignore_exitcode
47
+
48
+ def success?
49
+ @result.success? if @result.is_a?(Output)
50
+
51
+ false
52
+ end
53
+
54
+ def spinner=(value)
55
+ raise ArgumentError, 'spinner must implement #auto_spin and #stop methods' unless value.respond_to?(:auto_spin) && value.respond_to?(:stop)
56
+
57
+ @spinner = value
58
+ end
59
+
60
+ # Proxy method calls for methods that don't exist here to various other objects.
61
+ # This allows doing things like: `win_exec.remote_exec('Get-Process')` and
62
+ # calling the `exec` method on the `winrm_exec` object. This is done by
63
+ # checking method name prefixes and calling the corresponding method on the
64
+ # appropriate object. The prefix is removed from the method name before
65
+ # calling the method on the object. The supported prefixes are:
66
+ # local_ - calls the method on the @local_exec object
67
+ # remote_ - calls the method on the @winrm_exec object
68
+ # rspec_ - calls the method on the @rspec_test_cmds object
69
+ # module_archive_ - calls the method on the @ma_builder object
70
+ def method_missing(method, *args, **kwargs, &block)
71
+ if method.to_s.start_with?('local_') # proxy to local_exec
72
+ method = method.to_s.sub('local_', '').to_sym
73
+ @local_exec.send(method, *args, **kwargs, &block)
74
+ elsif method.to_s.start_with?('remote_') # proxy to winrm_exec
75
+ method = method.to_s.sub('remote_', '').to_sym
76
+ @winrm_exec.send(method, *args, **kwargs, &block)
77
+ elsif method.to_s.start_with?('rspec_') # proxy to rspec_test_cmds
78
+ method = method.to_s.sub('rspec_', '').to_sym
79
+ @rspec_test_cmds.send(method, *args, **kwargs, &block)
80
+ elsif method.to_s.start_with?('module_archive_') # proxy to ma_builder
81
+ method = method.to_s.sub('module_archive_', '').to_sym
82
+ @ma_builder.send(method, *args, **kwargs, &block)
83
+ else
84
+ super
85
+ end
86
+ end
87
+
88
+ # Proxy respond_to? for methods that don't exist here to various other objects.
89
+ # This allows doing things like: `win_exec.respond_to?(:remote_exec)` and
90
+ # checking if the `exec` method exists on the `winrm_exec` object. This is done by
91
+ # checking method name prefixes and calling the corresponding method on the
92
+ # appropriate object. The prefix is removed from the method name before
93
+ # calling the method on the object. The supported prefixes are:
94
+ # local_ - calls the method on the @local_exec object
95
+ # remote_ - calls the method on the @winrm_exec object
96
+ # rspec_ - calls the method on the @rspec_test_cmds object
97
+ # module_archive_ - calls the method on the @ma_builder object
98
+ def respond_to_missing?(method, include_private = false)
99
+ if method.to_s.start_with?('local_')
100
+ @local_exec.respond_to?(method.to_s.sub('local_', '').to_sym, include_private)
101
+ elsif method.to_s.start_with?('remote_')
102
+ @winrm_exec.respond_to?(method.to_s.sub('remote_', '').to_sym, include_private)
103
+ elsif method.to_s.start_with?('rspec_')
104
+ @rspec_test_cmds.respond_to?(method.to_s.sub('rspec_', '').to_sym, include_private)
105
+ elsif method.to_s.start_with?('module_archive_')
106
+ @ma_builder.respond_to?(method.to_s.sub('module_archive_', '').to_sym, include_private)
107
+ else
108
+ super
109
+ end
110
+ end
111
+
112
+ def run(*args, **kwargs)
113
+ logger.info "Running #{@title}"
114
+ @spinner&.auto_spin
115
+ result = if reuse_tunnel?
116
+ @iap_tunnel.start # ensure tunnel is running
117
+ run_in_current_scope(*args, **kwargs)
118
+ else
119
+ @iap_tunnel.stop # ensure tunnel is stopped
120
+ @iap_tunnel.with do # start tunnel for this block
121
+ run_in_current_scope(*args, **kwargs)
122
+ end
123
+ end
124
+ @spinner&.stop
125
+ @result = Output.new(result)
126
+ return if @result.pending_threaded?
127
+
128
+ @result.puts_combined
129
+ unless @result.success? || ignore_exitcode?
130
+ raise "Command failed with exit code #{@result.exitcode}: #{@result.stdout}\n#{@result.stderr}"
131
+ end
132
+ @result
133
+ rescue StandardError => e
134
+ logger.error "Error running #{@title}: #{e.message[0..100]}"
135
+ @result = Output.new(e)
136
+ raise e unless ignore_exitcode?
137
+ end
138
+
139
+ private
140
+
141
+ def run_in_current_scope(*args, **kwargs)
142
+ instance_exec(*args, **kwargs, &@block)
143
+ end
144
+
145
+ def wrap_spinner
146
+ @spinner.auto_spin if @spinner
147
+ yield if block_given?
148
+ ensure
149
+ @spinner.stop if @spinner
150
+ end
151
+ end
152
+
153
+ # Factory class for creating a WinExec object
154
+ class Factory
155
+ include CemWinSpec::Logging
156
+
157
+ attr_reader :current_local_exec, :current_winrm_exec, :current_conn_opts
158
+
159
+ def initialize(iap_tunnel, ma_builder, rspec_test_cmds)
160
+ @iap_tunnel = iap_tunnel
161
+ @ma_builder = ma_builder
162
+ @rspec_test_cmds = rspec_test_cmds
163
+ @current_local_exec = LocalExec.new
164
+ @current_winrm_exec = nil
165
+ @current_conn_opts = nil
166
+ end
167
+
168
+ # Build a WinExec object
169
+ # @param title [String] Title of the WinExec object
170
+ # @param merge [Boolean] Merge the current connection options with the new options, if applicable
171
+ # @param host [String] Hostname or IP address of the remote host
172
+ # @param port [Integer] Port of the remote host
173
+ # @param user [String] Username for the remote host
174
+ # @param pass [String] Password for the remote host
175
+ # @param opts [Hash] Additional options for the WinRM connection
176
+ # @return [Exec] An Exec object
177
+ def build(title, merge: true, user: nil, pass: nil, working_dir: nil, **opts, &block)
178
+ logger.debug "Building Wexec object for #{title}"
179
+ build_conn_opts(merge: merge, user: user, pass: pass, **opts)
180
+ logger.debug 'Created ConnectionOpts'
181
+ wexec = Exec.new(title, @current_local_exec, get_winrm_exec(working_dir), @iap_tunnel, @ma_builder, @rspec_test_cmds, &block)
182
+ wexec.reuse_tunnel = opts[:reuse_tunnel] if opts.key?(:reuse_tunnel)
183
+ wexec.ignore_exitcode = opts[:ignore_exitcode] if opts.key?(:ignore_exitcode)
184
+ wexec.spinner = opts[:spinner] if opts.key?(:spinner)
185
+ logger.debug 'Created Wexec'
186
+ wexec
187
+ end
188
+
189
+ def local_threaded_results
190
+ logger.info 'Checking for deferred results...'
191
+ @current_local_exec.join_threads
192
+ @current_local_exec.threaded_results.each do |cmd, results|
193
+ logger.info "Deferred results for #{cmd}:"
194
+ Output.new(results).puts_combined
195
+ end
196
+ end
197
+
198
+ private
199
+
200
+ def build_conn_opts(merge: true, user: nil, pass: nil, **opts)
201
+ if @current_conn_opts.nil?
202
+ logger.debug 'Creating new ConnectionOpts object'
203
+ @current_conn_opts = ConnectionOpts.new(new_host: 'localhost', new_port: @iap_tunnel.port, user: user, pass: pass, **opts)
204
+ return @current_conn_opts
205
+ end
206
+ opts_digest = Digest::SHA256.hexdigest(['localhost', @iap_tunnel.port, user, pass, opts].join(':'))
207
+ if opts_digest != @current_conn_opts.digest
208
+ if merge
209
+ logger.debug 'Merging ConnectionOpts with new options'
210
+ @current_conn_opts = @current_conn_opts.merge(new_host: 'localhost', new_port: @iap_tunnel.port, user: user, pass: pass, **opts)
211
+ else
212
+ logger.debug 'Creating new ConnectionOpts object with new options'
213
+ @current_conn_opts = ConnectionOpts.new(new_host: 'localhost', new_port: @iap_tunnel.port, user: user, pass: pass, **opts)
214
+ end
215
+ else
216
+ logger.debug 'Returning existing ConnectionOpts object'
217
+ end
218
+ @current_conn_opts
219
+ end
220
+
221
+
222
+ def get_winrm_exec(working_dir = nil)
223
+ if @current_winrm_exec.nil? || @current_winrm_exec.conn_opts.digest != @current_conn_opts.digest
224
+ logger.debug 'Creating new WinRMExec object'
225
+ @current_winrm_exec = WinRMExec.new(@current_conn_opts)
226
+ end
227
+ logger.debug "Setting working directory to #{working_dir}" unless working_dir.nil?
228
+ @current_winrm_exec.working_dir = working_dir
229
+ logger.debug 'Returning WinRMExec object'
230
+ @current_winrm_exec
231
+ end
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tty-spinner'
4
+ require_relative 'cem_win_spec/logging'
5
+ require_relative 'cem_win_spec/test_runner'
6
+
7
+ module CemWinSpec
8
+ class << self
9
+ include CemWinSpec::Logging
10
+ end
11
+
12
+ def self.signal_handler(runner)
13
+ Signal.trap('INT') do
14
+ puts 'Caught interrupt, killing tunnel and exiting'
15
+ runner.iap_tunnel.stop(wait: false, log: false)
16
+ exit 1
17
+ end
18
+ Signal.trap('TERM') do
19
+ puts 'Caught interrupt, killing tunnel and exiting'
20
+ runner.iap_tunnel.stop(wait: false, log: false)
21
+ exit 1
22
+ end
23
+ end
24
+
25
+ # Runs the tests
26
+ # @param options [Hash] Options for the test runner
27
+ # @option options [Boolean] :quiet (false) Whether to suppress output
28
+ # @option options [Boolean] :debug (false) Whether to enable debug output
29
+ # @option options [Boolean] :verbose (false) Whether to enable verbose output
30
+ # @option options [String] :log_level (nil) Explicitly set the log level
31
+ # @option options [String] :log_file (nil) Log output to file
32
+ # @option options [String] :log_format (nil) Set log format(text, json, github_action)
33
+ def self.run_tests(options = {})
34
+ raise 'Must be ran from the root of the project' unless File.exist?('Gemfile')
35
+
36
+ log_setup!(options)
37
+ logger.info 'Running tests'
38
+ logger.debug "Options: #{options}"
39
+ runner = TestRunner.new
40
+ logger.debug "Created TestRunner: #{runner}"
41
+ signal_handler(runner)
42
+ logger.debug 'Set up signal handler'
43
+ exitcode = 99
44
+ spinner = TTY::Spinner.new(format: :classic, interval: 1, hide_cursor: true, clear: true)
45
+ begin
46
+ runner.enable_long_paths.run
47
+ runner.enable_symlinks.run
48
+ working_dir_out = runner.create_working_dir.run
49
+ working_dir = working_dir_out&.stdout.chomp
50
+ logger.debug "Working dir: #{working_dir}"
51
+ module_dir = runner.upload_module(working_dir: working_dir).run&.stdout.chomp
52
+ logger.debug "Module dir: #{module_dir}"
53
+ runner.setup_ruby(operation_timeout: 300,
54
+ receive_timeout: 310,
55
+ working_dir: module_dir,
56
+ reuse_tunnel: false,
57
+ spinner: spinner).run
58
+ runner.rspec_prep(working_dir: module_dir, reuse_tunnel: false, spinner: spinner).run
59
+ spec_out = runner.rspec_tests_parallel(operation_timeout: 300,
60
+ receive_timeout: 310,
61
+ working_dir: module_dir,
62
+ ignore_exitcode: true,
63
+ reuse_tunnel: false,
64
+ spinner: spinner).run
65
+ # We currently don't have any local commands being run
66
+ # but we may in the future. If we do, we'll need to
67
+ # call local_threaded_results on the TestRunner
68
+ # runner.local_threaded_results
69
+ exitcode = spec_out&.exitcode
70
+ logger.info "Completed tests with exit code: #{exitcode}"
71
+ rescue StandardError => e
72
+ logger.fatal "Error: #{e.message}"
73
+ logger.debug e.backtrace.join("\n")
74
+ exitcode = 1
75
+ ensure
76
+ exit exitcode
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,4 @@
1
+ module CemWinSpec
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end