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,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tmpdir'
5
+ require_relative 'logging'
6
+
7
+ module CemWinSpec
8
+ class ModuleArchiveBuilder
9
+ include CemWinSpec::Logging
10
+
11
+ FILE_ALLOWLIST = %w[
12
+ data
13
+ files
14
+ lib
15
+ manifests
16
+ plans
17
+ spec
18
+ tasks
19
+ types
20
+ .fixtures.yml
21
+ .gitignore
22
+ .rspec
23
+ .rubocop.yml
24
+ .yardopts
25
+ hiera.yaml
26
+ Gemfile
27
+ metadata.json
28
+ Rakefile
29
+ ].freeze
30
+
31
+ attr_reader :module_dir, :allowlist, :tempdir, :archive
32
+
33
+ def initialize(module_dir = Dir.pwd, allowlist: FILE_ALLOWLIST)
34
+ @module_dir = module_dir
35
+ @module_name = File.basename(module_dir)
36
+ @allowlist = allowlist
37
+ end
38
+
39
+ alias path archive
40
+
41
+ def build
42
+ create_tempdir
43
+ copy_module_to_tempdir
44
+ remove_unwanted_files
45
+ create_archive
46
+ if block_given?
47
+ begin
48
+ yield archive
49
+ ensure
50
+ FileUtils.rm_rf(archive) if archive && File.exist?(archive)
51
+ end
52
+ end
53
+ ensure
54
+ FileUtils.rm_rf(tempdir) if tempdir && File.exist?(tempdir)
55
+ end
56
+
57
+ private
58
+
59
+ def create_tempdir
60
+ @tempdir = Dir.mktmpdir
61
+ logger.debug "Created tempdir #{tempdir}"
62
+ end
63
+
64
+ def copy_module_to_tempdir
65
+ FileUtils.cp_r(module_dir, tempdir)
66
+ logger.debug "Copied #{module_dir} to #{tempdir}"
67
+ @temp_module_dir = File.join(tempdir, @module_name)
68
+ end
69
+
70
+ def remove_unwanted_files
71
+ to_remove = Dir.glob("*", File::FNM_DOTMATCH, base: @temp_module_dir).reject { |f| allowlist.include?(f) || %w[. ..].include?(f) }
72
+ to_remove.each do |file|
73
+ FileUtils.rm_rf(File.join(@temp_module_dir, file))
74
+ logger.debug "Removed #{file} from #{@temp_module_dir}"
75
+ end
76
+ end
77
+
78
+ def create_archive
79
+ @archive = "#{@module_name}.tar.gz"
80
+ `tar -czf #{archive} -C #{tempdir} #{@module_name}`
81
+ logger.info "Created module archive #{archive}"
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake'
4
+ require 'rake/file_list'
5
+ require 'rake/tasklib'
6
+ require 'parallel_tests'
7
+ require_relative 'fixture_cache'
8
+
9
+ # Provides custom rake tasks to run tests on a remote Windows host.
10
+ # Add the following to your Rakefile to use:
11
+ # require 'cem_win_spec/rake_tasks'
12
+ # CemWinSpecRakeTasks::All.new
13
+ module CemWinSpecRakeTasks
14
+ # Class for defining all custom rake tasks
15
+ class All
16
+ def initialize(**opts)
17
+ @namespace = opts[:namespace] || :cem_win_spec
18
+ @data_dir = opts[:data_dir]
19
+ @hiera_conf = opts[:hiera_conf]
20
+ @pattern = opts[:pattern]
21
+ @fail_fast = opts[:fail_fast]
22
+ @format = opts[:format]
23
+ define_tasks
24
+ end
25
+
26
+ private
27
+
28
+ def define_tasks
29
+ define_prep_task
30
+ define_test_task
31
+ end
32
+
33
+ def define_prep_task
34
+ prep_task = Prep.new(@namespace)
35
+ prep_task.data_dir = @data_dir if @data_dir
36
+ prep_task.hiera_conf = @hiera_conf if @hiera_conf
37
+ prep_task.define_task
38
+ end
39
+
40
+ def define_test_task
41
+ test_task = Test.new(@namespace)
42
+ test_task.pattern = @pattern if @pattern
43
+ test_task.fail_fast = @fail_fast if @fail_fast
44
+ test_task.format = @format if @format
45
+ test_task.define_task
46
+ end
47
+ end
48
+
49
+ # Class for defining the cem_win_spec:prep task
50
+ class Prep
51
+ attr_accessor :data_dir, :hiera_conf
52
+
53
+ def initialize(namespace = :cem_win_spec)
54
+ @namespace = namespace
55
+ @data_dir = 'data'
56
+ @hiera_conf = 'hiera.yaml'
57
+ end
58
+
59
+ def define_task
60
+ namespace @namespace do
61
+ desc 'Prepare spec data'
62
+ task :prep do
63
+ if Gem.win_platform?
64
+ prep_data
65
+ prep_modules
66
+ else
67
+ puts 'Not on Windows, skipping prep'
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def fixtures(rel_path = nil)
76
+ fixtures_dir = File.expand_path(File.join('spec', 'fixtures'))
77
+ rel_path ? File.join(fixtures_dir, rel_path) : fixtures_dir
78
+ end
79
+
80
+ def prep_data
81
+ puts 'Preparing spec data...'
82
+ data_fix = fixtures(data_dir)
83
+ if File.directory?(data_fix)
84
+ puts "Removing old #{data_fix} directory..."
85
+ ::FileUtils.remove_entry_secure(data_fix)
86
+ end
87
+ puts "Copying Hiera data to #{data_fix}..."
88
+ ::FileUtils.cp_r(data_dir, data_fix)
89
+ unless Dir.glob("#{data_fix}/**/*").map { |f| f.delete_prefix('spec/fixtures/') } == Dir.glob("#{data_dir}/**/*")
90
+ raise 'Spec data copy failed!'
91
+ end
92
+ hiera_fix = fixtures(hiera_conf)
93
+ puts "Copying Hiera config to #{hiera_fix}..."
94
+ ::FileUtils.cp(hiera_conf, hiera_fix)
95
+ unless File.exist?(hiera_fix)
96
+ raise 'Hiera config copy failed!'
97
+ end
98
+ puts 'Spec data prepared successfully'
99
+ end
100
+
101
+ def prep_modules
102
+ puts 'Preparing module fixtures...'
103
+ mod_fix = fixtures('modules')
104
+ cache = CemWinSpec::FixtureCache.new
105
+ cache.copy_fixtures_to(mod_fix)
106
+ end
107
+ end
108
+
109
+ # Class for defining the cem_win_spec:test task
110
+ class Test
111
+ attr_accessor :pattern, :fail_fast, :format
112
+
113
+ def initialize(namespace = :cem_win_spec)
114
+ @namespace = namespace
115
+ @pattern = 'spec/{classes,data_tests,defines,functions,unit}/**/*_spec.rb'
116
+ @fail_fast = false
117
+ @format = 'progress'
118
+ end
119
+
120
+ def define_task
121
+ namespace @namespace do
122
+ desc 'Run RSpec tests on Windows host'
123
+ task :parallel_test do
124
+ spec_files = Rake::FileList[pattern]
125
+ pargs = ['--type', 'rspec']
126
+ rspec_args = ['--']
127
+ rspec_args << '--fail-fast' if fail_fast
128
+ rspec_argss.concat(['--format', @format])
129
+ pargs.concat(rspec_args)
130
+ pargs << '--'
131
+ puts "Running tests on Windows host with pattern #{pattern} and args #{pargs}..."
132
+ pargs.concat(spec_files)
133
+ ParallelTests::CLI.new.run(pargs)
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'iap_tunnel'
4
+ require_relative 'win_exec'
5
+
6
+ module CemWinSpec
7
+ # Class for running a command on a remote Windows host
8
+ class RemoteCommand
9
+ attr_reader :title, :result
10
+
11
+ def initialize(title, iap_tunnel: nil, reuse_tunnel: true, winrm_opts: {}, &block)
12
+ @title = title
13
+ @iap_tunnel = iap_tunnel || IapTunnel.new
14
+ @reuse_tunnel = reuse_tunnel
15
+ @winrm_opts = winrm_opts
16
+ @block = block
17
+ @win_exec = WinExec.new('localhost', @iap_tunnel.port, winrm_opts: winrm_opts)
18
+ @result = nil
19
+ end
20
+
21
+ def ran?
22
+ !@result.nil?
23
+ end
24
+
25
+ def success?
26
+ @result.is_a? WinRM::Output
27
+ end
28
+
29
+ def run
30
+ puts "Running #{@title}"
31
+ @result = if @reuse_tunnel
32
+ @iap_tunnel.start # ensure tunnel is running
33
+ @block.call @win_exec
34
+ else
35
+ @iap_tunnel.stop # ensure tunnel is stopped
36
+ @iap_tunnel.with do # start tunnel for this block
37
+ @block.call @win_exec
38
+ end
39
+ end
40
+ @result
41
+ rescue StandardError => e
42
+ puts "Error running #{@title}: #{e}"
43
+ @result = e
44
+ raise @result
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake'
4
+
5
+ module CemWinSpec
6
+ class RspecTestCmds
7
+ DEFAULT_PATTERN = 'spec/{classes,defines}/**/*_spec.rb'
8
+
9
+ def initialize(pattern: DEFAULT_PATTERN)
10
+ @pattern = pattern
11
+ end
12
+
13
+ def cmd_standalone(*args)
14
+ "bundle exec rake 'cem:spec_standalone#{rake_args(nil, *args)}'"
15
+ end
16
+
17
+ def cmd_parallel(*args)
18
+ "bundle exec rake 'cem:parallel_spec_standalone#{rake_args(*args)}'"
19
+ end
20
+
21
+ def cmd_chunked(*files)
22
+ "bundle exec rake 'cem:parallel_spec_files#{rake_args(files.join(' '))}'"
23
+ end
24
+
25
+ def prep_cmd
26
+ 'bundle exec rake cem:win_spec_prep'
27
+ end
28
+
29
+ def cmds(*args)
30
+ spec_files.to_a.collect { |file| rspec_cmd(file, *args) }
31
+ end
32
+
33
+ def cleanup_cmd
34
+ 'bundle exec rake cem:win_spec_clean'
35
+ end
36
+
37
+ def spec_files
38
+ @spec_files ||= Rake::FileList[@pattern]
39
+ end
40
+
41
+ private
42
+
43
+ def rake_args(*args)
44
+ args.empty? ? '' : "[#{args.join(',')}]"
45
+ end
46
+
47
+ def rspec_cmd(file, *args)
48
+ "bundle exec rake 'cem:spec_standalone#{rake_args(file, *args)}'"
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'fileutils'
5
+
6
+ require_relative 'iap_tunnel'
7
+ require_relative 'logging'
8
+ require_relative 'module_archive_builder'
9
+ require_relative 'rspec_test_cmds'
10
+ require_relative 'win_exec'
11
+
12
+ module CemWinSpec
13
+ # Class for running tests on a remote Windows host
14
+ class TestRunner
15
+ include CemWinSpec::Logging
16
+
17
+ attr_reader :iap_tunnel
18
+
19
+ def initialize
20
+ @module_archive_builder = ModuleArchiveBuilder.new
21
+ @rspec_cmds = RspecTestCmds.new
22
+ @iap_tunnel = IapTunnel.new
23
+ @win_exec_factory = CemWinSpec::WinExec::Factory.new(@iap_tunnel, @module_archive_builder, @rspec_cmds)
24
+ end
25
+
26
+ def run_all
27
+ check_connectivity.run
28
+ enable_long_paths.run
29
+ enable_symlinks.run
30
+ @working_dir = create_working_dir.run
31
+ upload_module.run(@working_dir)
32
+ setup_ruby.run(@working_dir)
33
+ rspec_tests_parallel.run(@working_dir)
34
+ ensure
35
+ clean_up(@working_dir)
36
+ @iap_tunnel.stop if @iap_tunnel.running?
37
+ end
38
+
39
+ def enable_long_paths(**opts)
40
+ new_command('Enable long paths', **opts) do
41
+ remote_exec('Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1')
42
+ end
43
+ end
44
+
45
+ def enable_symlinks(**opts)
46
+ @enable_symlinks ||= new_command('Enable symlinks', **opts) do
47
+ remote_exec('fsutil behavior set SymlinkEvaluation L2L:1 R2R:1 L2R:1 R2L:1')
48
+ end
49
+ end
50
+
51
+ def create_working_dir(**opts)
52
+ @create_working_dir ||= new_command('Create working directory', **opts) do
53
+ dir_name = if remote_conn_opts.user.nil?
54
+ "cem_windows_#{Time.now.to_i}"
55
+ else
56
+ "cem_windows_#{remote_conn_opts.user}_#{Time.now.to_i}"
57
+ end
58
+ work_dir = File.join(remote_temp_dir, dir_name.gsub(/[^0-9A-Za-z]/, '_')).gsub(%r{\/}, "\\")
59
+ remote_create_dir(work_dir)
60
+ work_dir
61
+ end
62
+ end
63
+
64
+ def upload_module(**opts)
65
+ @upload_module ||= new_command('Upload module', **opts) do
66
+ raise 'No working directory or working directory is not full path' unless remote_working_dir.match?(%r{^[a-zA-Z]:.*})
67
+
68
+ module_archive_build { |a| remote_upload(a, remote_working_dir) }
69
+ module_dir = "#{remote_working_dir}\\#{File.basename(module_archive_path, '.tar.gz')}"
70
+ logger.debug "Module uploaded to #{module_dir}.tar.gz, extracting..."
71
+ remote_exec("tar -xzf #{module_dir}.tar.gz -C #{remote_working_dir}")
72
+ logger.debug "Module extracted to #{module_dir}"
73
+ module_dir
74
+ end
75
+ end
76
+
77
+ def setup_ruby(**opts)
78
+ @setup_ruby ||= new_command('Set up ruby', **opts) do
79
+ remote_exec('bundle config disable_platform_warnings true')
80
+ remote_exec('bundle install')
81
+ end
82
+ end
83
+
84
+ def rspec_prep(**opts)
85
+ @rspec_prep ||= new_command('Prepare rspec tests', **opts) do
86
+ remote_exec('bundle exec rake cem_win_spec:prep')
87
+ end
88
+ end
89
+
90
+ def rspec_tests(**opts)
91
+ @rspec_tests ||= new_command('Run rspec tests', **opts) do
92
+ remote_exec(rspec_cmd_standalone('false', 'progress', 'true'))
93
+ end
94
+ end
95
+
96
+ def rspec_tests_parallel(**opts)
97
+ @rspec_tests_parallel ||= new_command('Run rspec tests in parallel', **opts) do
98
+ remote_exec('bundle exec rake cem_win_spec:parallel_test')
99
+ end
100
+ end
101
+
102
+ def clean_up
103
+ @clean_up ||= new_command('Cleanup') do |working_dir|
104
+ if remote_available?
105
+ remote_exec(cleanup_cmd, quiet: true)
106
+ else
107
+ logger.warn 'Cleanup not available'
108
+ end
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ def title_sym(title)
115
+ title.downcase.gsub(/\s*/, '_').to_sym
116
+ end
117
+
118
+ def new_command(title, **opts, &block)
119
+ logger.debug "Creating command #{title}"
120
+ wexec = @win_exec_factory.build(title, **opts, &block)
121
+ logger.info "Created command #{wexec.title}"
122
+ wexec
123
+ end
124
+
125
+ def run_remote(reuse_tunnel: true, &block)
126
+ if reuse_tunnel
127
+ @iap_tunnel.start # ensure tunnel is running
128
+ block.call
129
+ else
130
+ @iap_tunnel.stop # ensure tunnel is stopped
131
+ @iap_tunnel.with do # start tunnel for this block
132
+ block.call
133
+ end
134
+ end
135
+ end
136
+
137
+ def cleanup_cmd(working_dir)
138
+ <<~EOS
139
+ Get-ChildItem #{working_dir} -Recurse | Remove-Item -Force
140
+ Remove-Item -Force #{working_dir}
141
+ EOS
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CemWinSpec
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../logging'
4
+
5
+ module CemWinSpec
6
+ module WinExec
7
+ class BaseExec
8
+ include CemWinSpec::Logging
9
+
10
+ attr_accessor :working_dir
11
+
12
+ def initialize(working_dir = nil)
13
+ @working_dir = working_dir
14
+ end
15
+
16
+ def available?
17
+ raise NotImplementedError
18
+ end
19
+
20
+ def exec(cmd, *_args, **_kwargs)
21
+ raise NotImplementedError
22
+ end
23
+
24
+ private
25
+
26
+ def cd_working_dir(cmd)
27
+ return cmd if working_dir.nil?
28
+
29
+ "cd #{working_dir}; #{cmd}"
30
+ end
31
+
32
+ def cmd_prefix_msg
33
+ prefix = 'Executing command'
34
+ working_dir.nil? ? "#{prefix}:\n" : "#{prefix} in #{working_dir}:\n"
35
+ end
36
+
37
+ def puts_cmd(cmd)
38
+ logger.debug "#{cmd_prefix_msg}#{cmd.split(%r{\n|\r\n|;\s*}).map { |c| " #> #{c}" }.join("\n")}"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'io/console'
5
+ require_relative '../logging'
6
+
7
+ module CemWinSpec
8
+ module WinExec
9
+ # Class for holding and managing WinExec options
10
+ class ConnectionOpts
11
+ include CemWinSpec::Logging
12
+
13
+ CONN_DEFAULTS = {
14
+ transport: :ssl,
15
+ max_envelope_size: 307_200,
16
+ operation_timeout: 60,
17
+ receive_timeout: 70,
18
+ retry_limit: 3,
19
+ retry_delay: 10,
20
+ }.freeze
21
+ TPORT_DEFAULTS = {
22
+ negotiate: {},
23
+ ssl: {
24
+ no_ssl_peer_verification: true,
25
+ },
26
+ kerberos: {},
27
+ plaintext: {
28
+ basic_auth_only: true,
29
+ disable_sspi: true,
30
+ },
31
+ }.freeze
32
+
33
+ attr_reader :user
34
+
35
+ def initialize(new_host: nil, new_port: nil, user: nil, pass: nil, **kwargs)
36
+ @host = new_host
37
+ @port = new_port
38
+ @user = user || get_user
39
+ @pass = pass || get_pass
40
+ @kwargs = kwargs
41
+ end
42
+
43
+ def host
44
+ @host ||= 'localhost'
45
+ end
46
+
47
+ def port
48
+ @port ||= port_for_transport(opts[:transport])
49
+ end
50
+
51
+ def pass
52
+ @pass ||= Digest::SHA256.hexdigest(pt_pass) unless pt_pass.nil?
53
+ end
54
+
55
+ def endpoint
56
+ @endpoint ||= endpoint_for_transport(opts[:transport])
57
+ end
58
+
59
+ def opts
60
+ @opts ||= new_opts(@kwargs)
61
+ end
62
+
63
+ def ==(other)
64
+ raise ArgumentError, 'Can only compare ConnectionOpts with ConnectionOpts' unless other.is_a? ConnectionOpts
65
+
66
+ host == other.host &&
67
+ port == other.port &&
68
+ user == other.user &&
69
+ pass == other.pass &&
70
+ opts == other.opts
71
+ end
72
+
73
+ def to_h
74
+ opts.dup.merge({ user: user, password: pt_pass, endpoint: endpoint })
75
+ end
76
+
77
+ def digest
78
+ Digest::SHA256.hexdigest([host, port, user, @pass, opts.values].join(':'))
79
+ end
80
+
81
+ # Merge new options into existing options and return a new ConnectionOpts object
82
+ # @param [Hash] new_opts
83
+ # @return [ConnectionOpts]
84
+ def merge(**new_opts)
85
+ self.class.new(opts_merge(**new_opts))
86
+ end
87
+
88
+ def merge!(**new_opts)
89
+ @host = new_opts[:new_host] || host
90
+ @port = new_opts[:new_port] || port
91
+ @user = new_opts[:user] || user
92
+ @pass = new_opts[:pass] || pt_pass
93
+ @opts = new_opts(opts.merge(new_opts.reject { |k, _v| %i[new_host new_port user pass].include? k }.to_h))
94
+ self
95
+ end
96
+
97
+ protected
98
+
99
+ def pt_pass
100
+ @pass
101
+ end
102
+
103
+ private
104
+
105
+ def opts_merge(**new_opts)
106
+ {
107
+ new_host: new_opts[:new_host] || host,
108
+ new_port: new_opts[:new_port] || port,
109
+ user: new_opts[:user] || user,
110
+ pass: new_opts[:pass] || pt_pass,
111
+ **opts.merge(new_opts.reject { |k, _v| %i[new_host new_port user pass].include? k }.to_h),
112
+ }
113
+ end
114
+
115
+ def get_user
116
+ username = ENV['WINRM_USERNAME']
117
+
118
+ if username.nil? && !ENV['CI']
119
+ puts 'WinRM Username: '
120
+ username = $stdin.cooked(&:gets).chomp
121
+ end
122
+
123
+ raise 'No username provided' if username.nil? || username.empty?
124
+
125
+ logger.debug 'WinRM username is set'
126
+ username
127
+ end
128
+
129
+ def get_pass
130
+ password = ENV['WINRM_PASSWORD']
131
+
132
+ if password.nil? && !ENV['CI']
133
+ password = $stdin.getpass('WinRM Password: ')
134
+ end
135
+
136
+ raise 'No password provided' if password.nil? || password.empty?
137
+
138
+ logger.debug 'WinRM password is set'
139
+ password
140
+ end
141
+
142
+ def new_opts(nopts = {})
143
+ logger.debug 'Creating new connection options opts'
144
+ new_opts_h = CONN_DEFAULTS.dup.merge(nopts)
145
+ new_opts_h[:transport] = new_opts_h[:transport].to_sym
146
+ TPORT_DEFAULTS.dup[new_opts_h[:transport]].merge(new_opts_h)
147
+ end
148
+
149
+ def endpoint_for_transport(transport)
150
+ case transport
151
+ when :ssl
152
+ "https://#{host}:#{port}/wsman"
153
+ else
154
+ "http://#{host}:#{port}/wsman"
155
+ end
156
+ end
157
+
158
+ def port_for_transport(transport)
159
+ logger.debug "Getting default port for transport #{transport}, current port is #{@port}"
160
+ case transport
161
+ when :ssl
162
+ 5986
163
+ else
164
+ 5985
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end