diff --git a/.gitignore b/.gitignore index 9eedf918..75251011 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ *.swp .bundle .rvmrc +.versions.conf .config .yardoc .rspec @@ -21,3 +22,4 @@ test/version_tmp tmp Vagrantfile vendor/ +.DS_Store \ No newline at end of file diff --git a/Rakefile b/Rakefile index a44099c5..d93167fe 100644 --- a/Rakefile +++ b/Rakefile @@ -4,9 +4,9 @@ require 'rspec/core/rake_task' task :spec => 'spec:all' namespace :spec do - oses = %w( darwin debian gentoo redhat solaris solaris10 solaris11 smartos ) + oses = %w( darwin debian gentoo redhat solaris solaris10 solaris11 smartos windows) - task :all => [ oses.map {|os| "spec:#{os}" }, :helpers, :exec, :ssh ].flatten + task :all => [ oses.map {|os| "spec:#{os}" }, :helpers, :exec, :ssh, :cmd, :winrm, :powershell ].flatten oses.each do |os| RSpec::Core::RakeTask.new(os.to_sym) do |t| @@ -18,11 +18,9 @@ namespace :spec do t.pattern = "spec/helpers/*_spec.rb" end - RSpec::Core::RakeTask.new(:exec) do |t| - t.pattern = "spec/backend/exec/*_spec.rb" - end - - RSpec::Core::RakeTask.new(:ssh) do |t| - t.pattern = "spec/backend/ssh/*_spec.rb" + [:exec, :ssh, :cmd, :winrm, :powershell].each do |backend| + RSpec::Core::RakeTask.new(backend) do |t| + t.pattern = "spec/backend/#{backend.to_s}/*_spec.rb" + end end end diff --git a/WindowsSupport.md b/WindowsSupport.md new file mode 100644 index 00000000..3d2cdc40 --- /dev/null +++ b/WindowsSupport.md @@ -0,0 +1,88 @@ +## Windows support + +Serverspec is now providing a limited support for Microsoft Windows. + +If you want to test Windows based machines you need to set the target host's OS explicitly in your `spec/spec_helper.rb` + +For local testing (equivalent to the Exec option in Linux/Unix systems) simply do: + +```ruby +require 'serverspec' + +include Serverspec::Helper::Cmd +include Serverspec::Helper::Windows + +``` + +For remote testing you have to configure Windows Remote Management in order to communicate to the target host: + +```ruby +require 'serverspec' +require 'winrm' + +include Serverspec::Helper::WinRM +include Serverspec::Helper::Windows + +RSpec.configure do |c| + user = + pass = + endpoint = "http://:5985/wsman" + + c.winrm = ::WinRM::WinRMWebService.new(endpoint, :ssl, :user => user, :pass => pass, :basic_auth_only => true) + c.winrm.set_timeout 300 # 5 minutes max timeout for any operation +end +``` + +For different authentication mechanisms check the Microsoft WinRM documentation and verify the ones that are supported by [WinRb/WinRM](https://github.com/WinRb/WinRM) + + +###RSpec Examples for windows target hosts +```ruby +describe file('c:/windows') do + it { should be_directory } + it { should be_readable } + it { should_not be_writable.by('Everyone') } +end + +describe file('c:/temp/test.txt') do + it { should be_file } + it { should contain "some text" } +end + +describe package('Adobe AIR') do + it { should be_installed} +end + +describe service('DNS Client') do + it { should be_enabled } + it { should be_running } +end + +describe port(139) do + it { should be_listening } +end + +describe user('some.admin') do + it { should exist } + it { should belong_to_group('Administrators')} +end + +describe group('Guests') do + it { should exist } +end + +describe group('MYDOMAIN\Domain Users') do + it { should exist } +end + +describe windows_registry_key('HKEY_USERS\S-1-5-21-1319311448-2088773778-316617838-32407\Test MyKey') do + it { should exist } + it { should have_property('string value') } + it { should have_property('binary value', :type_binary) } + it { should have_property('dword value', :type_dword) } + it { should have_value('test default data') } + it { should have_property_value('multistring value', :type_multistring, "test\nmulti\nstring\ndata") } + it { should have_property_value('qword value', :type_qword, 'adff32') } + it { should have_property_value('binary value', :type_binary, 'dfa0f066') } +end +``` \ No newline at end of file diff --git a/bin/serverspec-init b/bin/serverspec-init index 44e2cb8e..ffa55c87 100755 --- a/bin/serverspec-init +++ b/bin/serverspec-init @@ -8,14 +8,24 @@ Serverspec::Setup.run __END__ require 'serverspec' +<% if @os_type == 'UN*X' -%> require 'pathname' +<% end -%> <% if @backend_type == 'Ssh' -%> require 'net/ssh' <% end -%> +<% if @backend_type == 'WinRM' -%> +require 'winrm' +<% end -%> include Serverspec::Helper::<%= @backend_type %> +<% if @os_type == 'UN*X' -%> include Serverspec::Helper::DetectOS +<% else -%> +include Serverspec::Helper::Windows +<% end -%> +<% if @os_type == 'UN*X' -%> RSpec.configure do |c| if ENV['ASK_SUDO_PASSWORD'] require 'highline/import' @@ -23,7 +33,7 @@ RSpec.configure do |c| else c.sudo_password = ENV['SUDO_PASSWORD'] end -<% if @backend_type == 'Ssh' -%> + <%- if @backend_type == 'Ssh' -%> c.before :all do block = self.class.metadata[:example_group_block] if RUBY_VERSION.start_with?('1.8') @@ -37,7 +47,7 @@ RSpec.configure do |c| c.host = host options = Net::SSH::Config.for(c.host) user = options[:user] || Etc.getlogin -<% if @vagrant -%> + <%- if @vagrant -%> vagrant_up = `vagrant up #{@hostname}` config = `vagrant ssh-config #{@hostname}` if config != '' @@ -53,9 +63,20 @@ RSpec.configure do |c| end end end -<% end -%> + <%- end -%> c.ssh = Net::SSH.start(c.host, user, options) end end + <%- end -%> +end <% end -%> +<% if @backend_type == 'WinRM'-%> +RSpec.configure do |c| + user = + pass = + endpoint = "http://:5985/wsman" + + c.winrm = ::WinRM::WinRMWebService.new(endpoint, :ssl, :user => user, :pass => pass, :basic_auth_only => true) + c.winrm.set_timeout 300 # 5 minutes max timeout for any operation end +<% end -%> diff --git a/lib/serverspec.rb b/lib/serverspec.rb index 6ebfc1cd..ecb83498 100644 --- a/lib/serverspec.rb +++ b/lib/serverspec.rb @@ -16,6 +16,7 @@ require 'serverspec/commands/solaris11' require 'serverspec/commands/smartos' require 'serverspec/commands/darwin' +require 'serverspec/commands/windows' require 'serverspec/configuration' require 'rspec/core/formatters/base_formatter' @@ -39,10 +40,12 @@ def configuration c.include(Serverspec::Helper::Solaris11, :os => :solaris11) c.include(Serverspec::Helper::SmartOS, :os => :smartos) c.include(Serverspec::Helper::Darwin, :os => :darwin) + c.include(Serverspec::Helper::Windows, :os => :windows) c.add_setting :os, :default => nil c.add_setting :host, :default => nil c.add_setting :ssh, :default => nil c.add_setting :sudo_password, :default => nil + c.add_setting :winrm, :default => nil Serverspec.configuration.defaults.each { |k, v| c.add_setting k, :default => v } c.before :each do backend.set_example(example) diff --git a/lib/serverspec/backend.rb b/lib/serverspec/backend.rb index 21c38319..97f9c2c2 100644 --- a/lib/serverspec/backend.rb +++ b/lib/serverspec/backend.rb @@ -1,2 +1,7 @@ +require 'serverspec/backend/base' require 'serverspec/backend/ssh' require 'serverspec/backend/exec' +require 'serverspec/backend/powershell/script_helper' +require 'serverspec/backend/powershell/command' +require 'serverspec/backend/cmd' +require 'serverspec/backend/winrm' diff --git a/lib/serverspec/backend/base.rb b/lib/serverspec/backend/base.rb new file mode 100644 index 00000000..4f07a8a2 --- /dev/null +++ b/lib/serverspec/backend/base.rb @@ -0,0 +1,31 @@ +require 'singleton' + +module Serverspec + module Backend + class Base + include Singleton + + def set_commands(c) + @commands = c + end + + def set_example(e) + @example = e + end + + def commands + @commands + end + + def check_zero(cmd, *args) + ret = run_command(commands.send(cmd, *args)) + ret[:exit_status] == 0 + end + + # Default action is to call check_zero with args + def method_missing(meth, *args, &block) + check_zero(meth, *args) + end + end + end +end \ No newline at end of file diff --git a/lib/serverspec/backend/cmd.rb b/lib/serverspec/backend/cmd.rb new file mode 100644 index 00000000..e2137a60 --- /dev/null +++ b/lib/serverspec/backend/cmd.rb @@ -0,0 +1,35 @@ +require 'open3' + +module Serverspec + module Backend + class Cmd < Base + include PowerShell::ScriptHelper + + def run_command(cmd, opts={}) + script = create_script(cmd) + result = execute_script script + + if @example + @example.metadata[:command] = script + @example.metadata[:stdout] = result[:stdout] + result[:stderr] + end + { :stdout => result[:stdout], :stderr => result[:stderr], + :exit_status => result[:status], :exit_signal => nil } + end + + def execute_script script + ps_script = %Q{powershell -encodedCommand #{encode_script(script)}} + if Open3.respond_to? :capture3 + stdout, stderr, status = Open3.capture3(ps_script) + # powershell still exits with 0 even if there are syntax errors, although it spits the error out into stderr + # so we have to resort to return an error exit code if there is anything in the standard error + status = 1 if status == 0 and !stderr.empty? + { :stdout => stdout, :stderr => stderr, :status => status } + else + stdout = `#{ps_script} 2>&1` + { :stdout => stdout, :stderr => nil, :status => $? } + end + end + end + end +end diff --git a/lib/serverspec/backend/exec.rb b/lib/serverspec/backend/exec.rb index 177f7353..25616489 100644 --- a/lib/serverspec/backend/exec.rb +++ b/lib/serverspec/backend/exec.rb @@ -2,20 +2,7 @@ module Serverspec module Backend - class Exec - include Singleton - - def set_commands(c) - @commands = c - end - - def set_example(e) - @example = e - end - - def commands - @commands - end + class Exec < Base def run_command(cmd, opts={}) cmd = build_command(cmd) @@ -52,16 +39,6 @@ def add_pre_command(cmd) cmd end - def check_zero(cmd, *args) - ret = run_command(commands.send(cmd, *args)) - ret[:exit_status] == 0 - end - - # Default action is to call check_zero with args - def method_missing(meth, *args, &block) - check_zero(meth, *args) - end - def check_running(process) ret = run_command(commands.check_running(process)) if ret[:exit_status] == 1 || ret[:stdout] =~ /stopped/ diff --git a/lib/serverspec/backend/powershell/command.rb b/lib/serverspec/backend/powershell/command.rb new file mode 100644 index 00000000..44a48cbb --- /dev/null +++ b/lib/serverspec/backend/powershell/command.rb @@ -0,0 +1,36 @@ +module Serverspec + module Backend + module PowerShell + class Command + attr_reader :import_functions, :script + def initialize &block + @import_functions = [] + @script = "" + instance_eval &block if block_given? + end + + def using *functions + functions.each { |f| import_functions << f } + end + + def exec code + @script = code + end + + def convert_regexp(target) + case target + when Regexp + target.source + else + target.to_s.gsub '/', '' + end + end + + def get_identity id + raise "You must provide a specific Windows user/group" if id =~ /(owner|group|others)/ + identity = id || 'Everyone' + end + end + end + end +end diff --git a/lib/serverspec/backend/powershell/script_helper.rb b/lib/serverspec/backend/powershell/script_helper.rb new file mode 100644 index 00000000..9957b8d2 --- /dev/null +++ b/lib/serverspec/backend/powershell/script_helper.rb @@ -0,0 +1,69 @@ +require 'base64' + +module Serverspec + module Backend + module PowerShell + module ScriptHelper + def build_command(cmd) + path = Serverspec.configuration.path || RSpec.configuration.path + if path + cmd.strip! + cmd = +<<-EOF +$env:path = "#{path};$env:path" +#{cmd} +EOF + end + cmd + end + + def add_pre_command(cmd) + path = Serverspec.configuration.path || RSpec.configuration.path + if Serverspec.configuration.pre_command + cmd.strip! + cmd = +<<-EOF +if (#{Serverspec.configuration.pre_command}) +{ +#{cmd} +} +EOF + cmd = "$env:path = \"#{path};$env:path\"\n#{cmd}" if path + end + cmd + end + + def encode_script script + script_text = script.chars.to_a.join("\x00").chomp + script_text << "\x00" unless script_text[-1].eql? "\x00" + if script_text.respond_to?(:encode) + script_text = script_text.encode('ASCII-8BIT') + end + if Base64.respond_to?(:strict_encode64) + Base64.strict_encode64(script_text) + else + [ script_text ].pack("m").strip + end + end + + def create_script command + script = build_command(command.script) + script = add_pre_command(script) + ps_functions = command.import_functions.map { |f| File.read(File.join(File.dirname(__FILE__), 'support', f)) } + <<-EOF +$exitCode = 1 +try { + #{ps_functions.join("\n")} + $success = (#{script}) + if ($success -is [Boolean] -and $success) { $exitCode = 0 } +} catch { + Write-Output $_.Exception.Message +} +Write-Output "Exiting with code: $exitCode" +exit $exitCode + EOF + end + end + end + end +end diff --git a/lib/serverspec/backend/powershell/support/check_file_access_rules.ps1 b/lib/serverspec/backend/powershell/support/check_file_access_rules.ps1 new file mode 100644 index 00000000..15cbe196 --- /dev/null +++ b/lib/serverspec/backend/powershell/support/check_file_access_rules.ps1 @@ -0,0 +1,12 @@ +function CheckFileAccessRules +{ + param($path, $identity, $rules) + + $result = $false + $accessRules = (Get-Acl $path).access | Where-Object {$_.AccessControlType -eq 'Allow' -and $_.IdentityReference -eq $identity } + if ($accessRules) { + $match = $accessRules.FileSystemRights.ToString() -Split (', ') | ?{$rules -contains $_} + $result = $match -ne $null -or $match.length -gt 0 + } + $result +} diff --git a/lib/serverspec/backend/powershell/support/crop_text.ps1 b/lib/serverspec/backend/powershell/support/crop_text.ps1 new file mode 100644 index 00000000..59f1d232 --- /dev/null +++ b/lib/serverspec/backend/powershell/support/crop_text.ps1 @@ -0,0 +1,11 @@ +function CropText +{ + param($text, $fromPattern, $toPattern) + + $from, $to = ([regex]::matches($text, $fromPattern)), ([regex]::matches($text, $toPattern)) + if ($from.count -gt 0 -and $to.count -gt 0) { + $text.substring($from[0].index, $to[0].index + $to[0].length - $from[0].index) + } else { + "" + } +} diff --git a/lib/serverspec/backend/powershell/support/find_group.ps1 b/lib/serverspec/backend/powershell/support/find_group.ps1 new file mode 100644 index 00000000..c8134389 --- /dev/null +++ b/lib/serverspec/backend/powershell/support/find_group.ps1 @@ -0,0 +1,8 @@ +function FindGroup +{ + param($groupName, $domain) + if ($domain -eq $null) {$selectionCriteria = " and LocalAccount = true"} + else {$selectionCriteria = " and Domain = '$domain'"} + + Get-WmiObject Win32_Group -filter "Name = '$groupName' $selectionCriteria" +} \ No newline at end of file diff --git a/lib/serverspec/backend/powershell/support/find_installed_application.ps1 b/lib/serverspec/backend/powershell/support/find_installed_application.ps1 new file mode 100644 index 00000000..ee3ce752 --- /dev/null +++ b/lib/serverspec/backend/powershell/support/find_installed_application.ps1 @@ -0,0 +1,7 @@ +function FindInstalledApplication +{ + param($appName, $appVersion) + $selectionCriteria = "(Name like '$appName' or PackageName like '$appName') and InstallState = 5" + if ($appVersion -ne $null) { $selectionCriteria += " and version = '$appVersion'"} + Get-WmiObject Win32_Product -filter $selectionCriteria +} \ No newline at end of file diff --git a/lib/serverspec/backend/powershell/support/find_service.ps1 b/lib/serverspec/backend/powershell/support/find_service.ps1 new file mode 100644 index 00000000..e779770e --- /dev/null +++ b/lib/serverspec/backend/powershell/support/find_service.ps1 @@ -0,0 +1,5 @@ +function FindService +{ + param($name) + Get-WmiObject Win32_Service | Where-Object {$_.serviceName -eq $name -or $_.displayName -eq $name} +} \ No newline at end of file diff --git a/lib/serverspec/backend/powershell/support/find_user.ps1 b/lib/serverspec/backend/powershell/support/find_user.ps1 new file mode 100644 index 00000000..b60c11fe --- /dev/null +++ b/lib/serverspec/backend/powershell/support/find_user.ps1 @@ -0,0 +1,8 @@ +function FindUser +{ + param($userName, $domain) + if ($domain -eq $null) {$selectionCriteria = " and LocalAccount = true"} + else {$selectionCriteria = " and Domain = '$domain'"} + + Get-WmiObject Win32_UserAccount -filter "Name = '$userName' $selectionCriteria" +} diff --git a/lib/serverspec/backend/powershell/support/find_usergroup.ps1 b/lib/serverspec/backend/powershell/support/find_usergroup.ps1 new file mode 100644 index 00000000..968c2f28 --- /dev/null +++ b/lib/serverspec/backend/powershell/support/find_usergroup.ps1 @@ -0,0 +1,9 @@ +function FindUserGroup +{ + param($userName, $groupName, $userDomain, $groupDomain) + $user = FindUser -userName $userName -domain $userDomain + $group = FindGroup -groupName $groupName -domain $groupDomain + if ($user -and $group) { + Get-WmiObject Win32_GroupUser -filter ("GroupComponent = 'Win32_Group.Domain=`"" + $group.domain + "`",Name=`"" + $group.name + "`"' and PartComponent = 'Win32_UserAccount.Domain=`"" + $user.domain + "`",Name=`"" + $user.name + "`"'") + } +} \ No newline at end of file diff --git a/lib/serverspec/backend/powershell/support/is_port_listening.ps1 b/lib/serverspec/backend/powershell/support/is_port_listening.ps1 new file mode 100644 index 00000000..a76634f6 --- /dev/null +++ b/lib/serverspec/backend/powershell/support/is_port_listening.ps1 @@ -0,0 +1,13 @@ +function IsPortListening +{ + param($portNumber, $protocol) + $netstatOutput = netstat -an | Out-String + $networkIPs = (Get-WmiObject Win32_NetworkAdapterConfiguration | ? {$_.IPEnabled}) | %{ $_.IPAddress[0] } + foreach ($ipaddress in $networkIPs) + { + $matchExpression = ("$ipaddress" + ":" + $portNumber) + if ($protocol) { $matchExpression = ($protocol.toUpper() + "\s+$matchExpression") } + if ($netstatOutput -match $matchExpression) { return $true } + } + $false +} \ No newline at end of file diff --git a/lib/serverspec/backend/winrm.rb b/lib/serverspec/backend/winrm.rb new file mode 100644 index 00000000..1c1dff8a --- /dev/null +++ b/lib/serverspec/backend/winrm.rb @@ -0,0 +1,26 @@ +module Serverspec + module Backend + class WinRM < Base + include PowerShell::ScriptHelper + + def run_command(cmd, opts={}) + script = create_script(cmd) + winrm = RSpec.configuration.winrm + + result = winrm.powershell(script) + stdout, stderr = [:stdout, :stderr].map do |s| + result[:data].select {|item| item.key? s}.map {|item| item[s]}.join + end + result[:exitcode] = 1 if result[:exitcode] == 0 and !stderr.empty? + + if @example + @example.metadata[:command] = script + @example.metadata[:stdout] = stdout + stderr + end + + { :stdout => stdout, :stderr => stderr, + :exit_status => result[:exitcode], :exit_signal => nil } + end + end + end +end diff --git a/lib/serverspec/commands/windows.rb b/lib/serverspec/commands/windows.rb new file mode 100644 index 00000000..6229b88f --- /dev/null +++ b/lib/serverspec/commands/windows.rb @@ -0,0 +1,211 @@ +module Serverspec + module Commands + class Windows + class NotSupportedError < Exception; end + REGISTRY_KEY_TYPES = { + :type_string => 'String', + :type_binary => 'Binary', + :type_dword => 'DWord', + :type_qword => 'QWord', + :type_multistring => 'MultiString', + :type_expandstring => 'ExpandString' + } + + def method_missing method, *args + raise NotSupportedError.new "#{method} currently not supported in Windows os" if method.to_s =~ /^check_.+/ + super(method, *args) + end + + def check_file(file) + cmd = item_has_attribute file, 'Archive' + Backend::PowerShell::Command.new do + exec cmd + end + end + + def check_file_hidden(file) + cmd = item_has_attribute file, 'Hidden' + Backend::PowerShell::Command.new do + exec cmd + end + end + + def check_file_readonly(file) + cmd = item_has_attribute file, 'ReadOnly' + Backend::PowerShell::Command.new do + exec cmd + end + end + + def check_file_system(file) + cmd = item_has_attribute file, 'System' + Backend::PowerShell::Command.new do + exec cmd + end + end + + def check_directory(dir) + cmd = item_has_attribute dir, 'Directory' + Backend::PowerShell::Command.new do + exec cmd + end + end + + def check_file_contain(file, pattern) + Backend::PowerShell::Command.new do + exec "[Io.File]::ReadAllText('#{file}') -match '#{convert_regexp(pattern)}'" + end + end + + def check_file_contain_within file, pattern, from=nil, to=nil + from ||= '^' + to ||= '$' + Backend::PowerShell::Command.new do + using 'crop_text.ps1' + exec %Q[(CropText -text ([Io.File]::ReadAllText('#{file}')) -fromPattern '#{convert_regexp(from)}' -toPattern '#{convert_regexp(to)}') -match '#{pattern}'] + end + end + + def check_access_by_user(file, user, access) + case access + when 'r' + check_readable(file, user) + when 'w' + check_writable(file, user) + when 'x' + check_executable(file, user) + end + end + + def check_readable(file, by_whom) + Backend::PowerShell::Command.new do + using 'check_file_access_rules.ps1' + exec "CheckFileAccessRules -path '#{file}' -identity '#{get_identity by_whom}' -rules @('FullControl', 'Modify', 'ReadAndExecute', 'Read', 'ListDirectory')" + end + end + + def check_writable(file, by_whom) + Backend::PowerShell::Command.new do + using 'check_file_access_rules.ps1' + exec "CheckFileAccessRules -path '#{file}' -identity '#{get_identity by_whom}' -rules @('FullControl', 'Modify', 'Write')" + end + end + + def check_executable(file, by_whom) + Backend::PowerShell::Command.new do + using 'check_file_access_rules.ps1' + exec "CheckFileAccessRules -path '#{file}' -identity '#{get_identity by_whom}' -rules @('FullControl', 'Modify', 'ReadAndExecute', 'ExecuteFile')" + end + end + + def check_installed(package, version=nil) + version_selection = version.nil? ? "" : "-appVersion '#{version}'" + Backend::PowerShell::Command.new do + using 'find_installed_application.ps1' + exec "(FindInstalledApplication -appName '#{package}' #{version_selection}) -ne $null" + end + end + + def check_enabled(service, level=nil) + Backend::PowerShell::Command.new do + using 'find_service.ps1' + exec "(FindService -name '#{service}').StartMode -eq 'Auto'" + end + end + + def check_running(service) + Backend::PowerShell::Command.new do + using 'find_service.ps1' + exec "(FindService -name '#{service}').State -eq 'Running'" + end + end + + def check_process(process) + Backend::PowerShell::Command.new do + exec "(Get-Process '#{process}') -ne $null" + end + end + + def check_listening(port) + Backend::PowerShell::Command.new do + using 'is_port_listening.ps1' + exec "IsPortListening -portNumber #{port}" + end + end + + def check_listening_with_protocol(port, protocol) + Backend::PowerShell::Command.new do + using 'is_port_listening.ps1' + exec "IsPortListening -portNumber #{port} -protocol '#{protocol}'" + end + end + + def check_user(user) + user_id, domain = windows_account user + Backend::PowerShell::Command.new do + using 'find_user.ps1' + exec "(FindUser -userName '#{user_id}'#{domain.nil? ? "" : " -domain '#{domain}'"}) -ne $null" + end + end + + def check_group(group) + group_id, domain = windows_account group + Backend::PowerShell::Command.new do + using 'find_group.ps1' + exec "(FindGroup -groupName '#{group_id}'#{domain.nil? ? "" : " -domain '#{domain}'"}) -ne $null" + end + end + + def check_belonging_group(user, group) + user_id, user_domain = windows_account user + group_id, group_domain = windows_account group + Backend::PowerShell::Command.new do + using 'find_user.ps1' + using 'find_group.ps1' + using 'find_usergroup.ps1' + exec "(FindUserGroup -userName '#{user_id}'#{user_domain.nil? ? "" : " -userDomain '#{user_domain}'"} -groupName '#{group_id}'#{group_domain.nil? ? "" : " -groupDomain '#{group_domain}'"}) -ne $null" + end + end + + def check_registry_key(key_name, key_property = {}) + if key_property.empty? + cmd = "(Get-Item 'Registry::#{key_name}') -ne $null" + else + if key_property.key? :value + value = convert_key_property_value key_property + cmd = "(Compare-Object (Get-Item 'Registry::#{key_name}').GetValue('#{key_property[:name]}') #{value}) -eq $null" + else + cmd = "(Get-Item 'Registry::#{key_name}').GetValueKind('#{key_property[:name]}') -eq '#{REGISTRY_KEY_TYPES[key_property[:type]]}'" + end + end + Backend::PowerShell::Command.new { exec cmd } + end + + private + + def item_has_attribute item, attribute + "((Get-Item -Path '#{item}' -Force).attributes.ToString() -Split ', ') -contains '#{attribute}'" + end + + def convert_key_property_value property + case property[:type] + when :type_binary + byte_array = [property[:value]].pack('H*').bytes.to_a + "([byte[]] #{byte_array.join(',')})" + when:type_dword, :type_qword + property[:value].hex + else + string_array = property[:value].split("\n").map {|s| "'#{s}'"}.reduce {|acc, s| "#{acc},#{s}"} + "@(#{string_array})" + end + end + + def windows_account account + match = /((.+)\\)?(.+)/.match account + domain = match[2] + name = match[3] + [name, domain] + end + end + end +end diff --git a/lib/serverspec/helper.rb b/lib/serverspec/helper.rb index 2e8439c2..5ee03ab9 100644 --- a/lib/serverspec/helper.rb +++ b/lib/serverspec/helper.rb @@ -3,6 +3,8 @@ # Backend helpers require 'serverspec/helper/ssh' require 'serverspec/helper/exec' +require 'serverspec/helper/cmd' +require 'serverspec/helper/winrm' require 'serverspec/helper/puppet' # Command helpers @@ -14,6 +16,7 @@ require 'serverspec/helper/solaris11' require 'serverspec/helper/smartos' require 'serverspec/helper/darwin' +require 'serverspec/helper/windows' require 'serverspec/helper/detect_os' # Attributes helper diff --git a/lib/serverspec/helper/cmd.rb b/lib/serverspec/helper/cmd.rb new file mode 100644 index 00000000..a4bb9dc0 --- /dev/null +++ b/lib/serverspec/helper/cmd.rb @@ -0,0 +1,15 @@ +module Serverspec + module Helper + module Cmd + def backend(commands_object=nil) + if ! respond_to?(:commands) + commands_object = Serverspec::Commands::Windows.new + end + instance = Serverspec::Backend::Cmd.instance + instance.set_commands(commands_object || commands) + instance + end + end + end +end + diff --git a/lib/serverspec/helper/type.rb b/lib/serverspec/helper/type.rb index 3c6a1577..0173fc23 100644 --- a/lib/serverspec/helper/type.rb +++ b/lib/serverspec/helper/type.rb @@ -4,7 +4,7 @@ module Type types = %w( base yumrepo service package port file cron command linux_kernel_parameter iptables host routing_table default_gateway selinux user group zfs ipnat ipfilter kernel_module interface php_config - mail_alias + mail_alias windows_registry_key ) types.each {|type| require "serverspec/type/#{type}" } diff --git a/lib/serverspec/helper/windows.rb b/lib/serverspec/helper/windows.rb new file mode 100644 index 00000000..5f0d7cef --- /dev/null +++ b/lib/serverspec/helper/windows.rb @@ -0,0 +1,9 @@ +module Serverspec + module Helper + module Windows + def commands + Serverspec::Commands::Windows.new + end + end + end +end diff --git a/lib/serverspec/helper/winrm.rb b/lib/serverspec/helper/winrm.rb new file mode 100644 index 00000000..d0780d89 --- /dev/null +++ b/lib/serverspec/helper/winrm.rb @@ -0,0 +1,15 @@ +module Serverspec + module Helper + module WinRM + def backend(commands_object=nil) + if ! respond_to?(:commands) + commands_object = Serverspec::Commands::Windows.new + end + instance = Serverspec::Backend::WinRM.instance + instance.set_commands(commands_object || commands) + instance + end + end + end +end + diff --git a/lib/serverspec/setup.rb b/lib/serverspec/setup.rb index fd37cb19..03524f7b 100644 --- a/lib/serverspec/setup.rb +++ b/lib/serverspec/setup.rb @@ -4,19 +4,15 @@ module Serverspec class Setup def self.run - prompt = <<-EOF -Select a backend type: - 1) SSH - 2) Exec (local) + ask_os_type -Select number: -EOF - print prompt.chop - num = gets.to_i - 1 - puts + if @os_type == 'UN*X' + ask_unix_backend + else + ask_windows_backend + end - @backend_type = [ 'Ssh', 'Exec' ][num] || 'Exec' if @backend_type == 'Ssh' print "Vagrant instance y/n: " @vagrant = gets.chomp @@ -38,12 +34,62 @@ def self.run else @hostname = 'localhost' end + [ 'spec', "spec/#{@hostname}" ].each { |dir| safe_mkdir(dir) } safe_create_spec safe_create_spec_helper safe_create_rakefile end + def self.ask_os_type + prompt = <<-EOF +Select OS type: + + 1) UN*X + 2) Windows + +Select number: +EOF + + print prompt.chop + num = gets.to_i - 1 + puts + + @os_type = [ 'UN*X', 'Windows' ][num] || 'UN*X' + end + + def self.ask_unix_backend + prompt = <<-EOF +Select a backend type: + + 1) SSH + 2) Exec (local) + +Select number: +EOF + print prompt.chop + num = gets.to_i - 1 + puts + + @backend_type = [ 'Ssh', 'Exec' ][num] || 'Exec' + end + + def self.ask_windows_backend + prompt = <<-EOF +Select a backend type: + + 1) WinRM + 2) Cmd (local) + +Select number: +EOF + print prompt.chop + num = gets.to_i - 1 + puts + + @backend_type = [ 'WinRM', 'Cmd' ][num] || 'Exec' + end + def self.safe_create_spec content = <<-EOF require 'spec_helper' diff --git a/lib/serverspec/type/windows_registry_key.rb b/lib/serverspec/type/windows_registry_key.rb new file mode 100644 index 00000000..8987cb48 --- /dev/null +++ b/lib/serverspec/type/windows_registry_key.rb @@ -0,0 +1,21 @@ +module Serverspec + module Type + class WindowsRegistryKey < Base + def exists? + backend.check_registry_key(@name) + end + + def has_property?(property_name, property_type = :type_string) + backend.check_registry_key(@name, {:name => property_name, :type => property_type}) + end + + def has_value?(value) + backend.check_registry_key(@name, {:name => '', :type => :type_string, :value => value}) + end + + def has_property_value?(property_name, property_type, value) + backend.check_registry_key(@name, {:name => property_name, :type => property_type, :value => value}) + end + end + end +end diff --git a/spec/backend/cmd/configuration_spec.rb b/spec/backend/cmd/configuration_spec.rb new file mode 100644 index 00000000..75b10ac7 --- /dev/null +++ b/spec/backend/cmd/configuration_spec.rb @@ -0,0 +1,9 @@ +require 'spec_helper' +require 'support/powershell_command_runner' + +include Serverspec::Helper::Cmd +include Serverspec::Helper::Windows + +describe "Cmd" do + it_behaves_like "a powershell command runner" +end diff --git a/spec/backend/powershell/script_helper_spec.rb b/spec/backend/powershell/script_helper_spec.rb new file mode 100644 index 00000000..411ef754 --- /dev/null +++ b/spec/backend/powershell/script_helper_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' + +include Serverspec::Backend::PowerShell::ScriptHelper + +describe 'build command with path' do + before :each do + RSpec.configure do |c| + c.path = 'c:/test/path/bin' + end + end + + it "should prefix the command with the path instruction" do + cmd = build_command('run_script -f param') + cmd.should eq <<-eof +$env:path = "c:/test/path/bin;$env:path" +run_script -f param +eof + end + + after :each do + RSpec.configure do |c| + c.path = nil + end + end +end + +describe 'add pre-command' do + before :each do + Serverspec.configuration.pre_command = 'test_pre_command' + end + + it "should add the test for pre_command before the command" do + cmd = add_pre_command('run_script -f param') + cmd.should eq <<-eof +if (test_pre_command) +{ +run_script -f param +} +eof + end + + context "with path" do + before :each do + RSpec.configure do |c| + c.path = 'c:/test/path/bin' + end + end + + it "should add the path instruction and the test for pre_command before the command" do + cmd = add_pre_command('run_script -f param') + cmd.should eq <<-eof +$env:path = "c:/test/path/bin;$env:path" +if (test_pre_command) +{ +run_script -f param +} +eof + end + + after :each do + RSpec.configure do |c| + c.path = nil + end + end + end + + after :each do + Serverspec.configuration.pre_command = nil + end +end + +describe "script encoding" do + it "should encode the given script" do + script = encode_script("test_powershell_script") + script.should == "dABlAHMAdABfAHAAbwB3AGUAcgBzAGgAZQBsAGwAXwBzAGMAcgBpAHAAdAA=" + end +end diff --git a/spec/backend/winrm/configuration_spec.rb b/spec/backend/winrm/configuration_spec.rb new file mode 100644 index 00000000..71bbfd12 --- /dev/null +++ b/spec/backend/winrm/configuration_spec.rb @@ -0,0 +1,9 @@ +require 'spec_helper' +require 'support/powershell_command_runner' + +include Serverspec::Helper::WinRM +include Serverspec::Helper::Windows + +describe "WinRM" do + it_behaves_like "a powershell command runner" +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 126271ba..28436355 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -11,10 +11,8 @@ module Serverspec module Backend - class Exec - def run_command(cmd) - cmd = build_command(cmd) - cmd = add_pre_command(cmd) + module TestCommandRunner + def do_run cmd if @example @example.metadata[:subject].set_command(cmd) end @@ -36,29 +34,23 @@ def run_command(cmd) end end end - - class Ssh - def run_command(cmd) - cmd = build_command(cmd) - cmd = add_pre_command(cmd) - if @example - @example.metadata[:subject].set_command(cmd) + [Exec, Ssh].each do |clz| + clz.class_eval do + include TestCommandRunner + def run_command(cmd) + cmd = build_command(cmd) + cmd = add_pre_command(cmd) + do_run cmd end - - if cmd =~ /invalid/ - { - :stdout => ::Serverspec.configuration.stdout, - :stderr => ::Serverspec.configuration.stderr, - :exit_status => 1, - :exit_signal => nil - } - else - { - :stdout => ::Serverspec.configuration.stdout, - :stderr => ::Serverspec.configuration.stderr, - :exit_status => 0, - :exit_signal => nil - } + end + end + [Cmd, WinRM].each do |clz| + clz.class_eval do + include TestCommandRunner + def run_command(cmd) + cmd = build_command(cmd.script) + cmd = add_pre_command(cmd) + do_run cmd end end end diff --git a/spec/support/powershell_command_runner.rb b/spec/support/powershell_command_runner.rb new file mode 100644 index 00000000..165b0813 --- /dev/null +++ b/spec/support/powershell_command_runner.rb @@ -0,0 +1,52 @@ +shared_examples "a powershell command runner" do + describe 'configurations are not set' do + context file('/some/file') do + it { should be_file } + its(:command) { should == "((Get-Item -Path '/some/file' -Force).attributes.ToString() -Split ', ') -contains 'Archive'" } + end + end + + describe 'path is set' do + let(:path) { 'c:/path/bin' } + context file('/some/file') do + it { should be_file } + its(:command) { + should == <<-eof +$env:path = "c:/path/bin;$env:path" +((Get-Item -Path '/some/file' -Force).attributes.ToString() -Split ', ') -contains 'Archive' +eof + } + end + end + + describe 'pre_command is set' do + let(:pre_command) { 'some_other_command' } + context file('/some/file') do + it { should be_file } + its(:command) { should == <<-eof +if (some_other_command) +{ +((Get-Item -Path '/some/file' -Force).attributes.ToString() -Split ', ') -contains 'Archive' +} +eof + } + end + end + + describe 'path and pre_command are set' do + let(:path) { 'c:/path/bin' } + let(:pre_command) { 'some_other_command' } + context file('/some/file') do + it { should be_file } + its(:command) { should == <<-eof +$env:path = "c:/path/bin;$env:path" +if (some_other_command) +{ +$env:path = "c:/path/bin;$env:path" +((Get-Item -Path '/some/file' -Force).attributes.ToString() -Split ', ') -contains 'Archive' +} +eof + } + end + end +end \ No newline at end of file diff --git a/spec/windows/file_spec.rb b/spec/windows/file_spec.rb new file mode 100644 index 00000000..acee9799 --- /dev/null +++ b/spec/windows/file_spec.rb @@ -0,0 +1,161 @@ +require 'spec_helper' + +include Serverspec::Helper::Cmd +include Serverspec::Helper::Windows + +describe file('/some/valid/file') do + it { should be_file } + its(:command) { should == "((Get-Item -Path '/some/valid/file' -Force).attributes.ToString() -Split ', ') -contains 'Archive'" } +end + +describe file('/some/invalid/file') do + it { should_not be_file } +end + +describe file('/some/valid/folder') do + it { should be_directory } + its(:command) { should == "((Get-Item -Path '/some/valid/folder' -Force).attributes.ToString() -Split ', ') -contains 'Directory'" } +end + +describe file('/some/invalid/folder') do + it { should_not be_directory } +end + +describe file('/some/file') do + it { should contain 'search text' } + its(:command) { should == "[Io.File]::ReadAllText('/some/file') -match 'search text'" } +end + +describe file('/some/file') do + it { should contain /^search text/ } + its(:command) { should == "[Io.File]::ReadAllText('/some/file') -match '^search text'" } +end + +describe file('/some/file') do + it { should_not contain 'This is invalid text!!' } +end + +describe file('Gemfile') do + it { should contain('rspec').from(/^group :test do/).to(/^end/) } + its(:command) { should == "(CropText -text ([Io.File]::ReadAllText('Gemfile')) -fromPattern '^group :test do' -toPattern '^end') -match 'rspec'" } +end + +describe file('/some/file') do + it { should_not contain('This is invalid text!!').from(/^group :test do/).to(/^end/) } +end + +describe file('Gemfile') do + it { should contain('rspec').after(/^group :test do/) } + its(:command) { should == "(CropText -text ([Io.File]::ReadAllText('Gemfile')) -fromPattern '^group :test do' -toPattern '$') -match 'rspec'" } +end + +describe file('Gemfile') do + it { should_not contain('This is invalid text!!').after(/^group :test do/) } +end + +describe file('Gemfile') do + it { should contain('rspec').before(/end/) } + its(:command) { should == "(CropText -text ([Io.File]::ReadAllText('Gemfile')) -fromPattern '^' -toPattern 'end') -match 'rspec'" } +end + +describe file('Gemfile') do + it { should_not contain('This is invalid text!!').before(/^end/) } +end + +describe file('/some/file') do + it { should be_readable } + its(:command) { should eq "CheckFileAccessRules -path '/some/file' -identity 'Everyone' -rules @('FullControl', 'Modify', 'ReadAndExecute', 'Read', 'ListDirectory')" } +end + +describe file('/some/invalid/file') do + it { should_not be_readable } +end + +describe file('/some/file') do + it "should raise error if trying to check access by 'owner' or 'group' or 'others'" do + ['owner', 'group', 'others'].each do |access| + expect { should be_readable.by(access) }.to raise_error + end + end +end + +describe file('/some/file') do + it { should be_readable.by('test.identity') } + its(:command) { should eq "CheckFileAccessRules -path '/some/file' -identity 'test.identity' -rules @('FullControl', 'Modify', 'ReadAndExecute', 'Read', 'ListDirectory')" } +end + +describe file('/some/file') do + it { should be_readable.by_user('test.identity') } + its(:command) { should eq "CheckFileAccessRules -path '/some/file' -identity 'test.identity' -rules @('FullControl', 'Modify', 'ReadAndExecute', 'Read', 'ListDirectory')" } +end + +describe file('/some/file') do + it { should be_writable } + its(:command) { should eq "CheckFileAccessRules -path '/some/file' -identity 'Everyone' -rules @('FullControl', 'Modify', 'Write')" } +end + +describe file('/some/invalid/file') do + it { should_not be_writable } +end + +describe file('/some/file') do + it "should raise error if trying to check access by 'owner' or 'group' or 'others'" do + ['owner', 'group', 'others'].each do |access| + expect { should be_writable.by(access) }.to raise_error + end + end +end + +describe file('/some/file') do + it { should be_writable.by('test.identity') } + its(:command) { should eq "CheckFileAccessRules -path '/some/file' -identity 'test.identity' -rules @('FullControl', 'Modify', 'Write')" } +end + +describe file('/some/file') do + it { should be_writable.by_user('test.identity') } + its(:command) { should eq "CheckFileAccessRules -path '/some/file' -identity 'test.identity' -rules @('FullControl', 'Modify', 'Write')" } +end + +describe file('/some/file') do + it { should be_executable } + its(:command) { should eq "CheckFileAccessRules -path '/some/file' -identity 'Everyone' -rules @('FullControl', 'Modify', 'ReadAndExecute', 'ExecuteFile')" } +end + +describe file('/some/invalid/file') do + it { should_not be_executable } +end + +describe file('/some/file') do + it "should raise error if trying to check access by 'owner' or 'group' or 'others'" do + ['owner', 'group', 'others'].each do |access| + expect { should be_executable.by(access) }.to raise_error + end + end +end + +describe file('/some/file') do + it { should be_executable.by('test.identity') } + its(:command) { should eq "CheckFileAccessRules -path '/some/file' -identity 'test.identity' -rules @('FullControl', 'Modify', 'ReadAndExecute', 'ExecuteFile')" } +end + +describe file('/some/file') do + it { should be_executable.by_user('test.identity') } + its(:command) { should eq "CheckFileAccessRules -path '/some/file' -identity 'test.identity' -rules @('FullControl', 'Modify', 'ReadAndExecute', 'ExecuteFile')" } +end + +describe file('/some/test/file') do + it "should raise error if command is not supported" do + { + :be_socket => [], + :be_mode => 644, + :be_owned_by => 'root', + :be_grouped_into => 'root', + :be_linked_to => '/some/other/file', + :be_mounted => [], + :match_md5checksum => '35435ea447c19f0ea5ef971837ab9ced', + :match_sha256checksum => '0c3feee1353a8459f8c7d84885e6bc602ef853751ffdbce3e3b6dfa1d345fc7a' + }.each do |method, args| + expect { should self.send(method, *args) }.to raise_error Serverspec::Commands::Windows::NotSupportedError + end + end +end diff --git a/spec/windows/group_spec.rb b/spec/windows/group_spec.rb new file mode 100644 index 00000000..35c706ca --- /dev/null +++ b/spec/windows/group_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +include Serverspec::Helper::Cmd +include Serverspec::Helper::Windows + +describe group('test.group') do + it { should exist } + its(:command) { should eq "(FindGroup -groupName 'test.group') -ne $null" } +end + +describe group('test.domain\test.group') do + it { should exist } + its(:command) { should eq "(FindGroup -groupName 'test.group' -domain 'test.domain') -ne $null" } +end + +describe group('invalid-group') do + it { should_not exist } +end + +describe group('test.group') do + it "should raise error if command is not supported" do + { + :have_gid => [nil], + }.each do |method, args| + expect { should self.send(method, *args) }.to raise_error Serverspec::Commands::Windows::NotSupportedError + end + end +end + diff --git a/spec/windows/port_spec.rb b/spec/windows/port_spec.rb new file mode 100644 index 00000000..558d2f8b --- /dev/null +++ b/spec/windows/port_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +include Serverspec::Helper::Cmd +include Serverspec::Helper::Windows + +describe port(80) do + it { should be_listening } + its(:command) { should eq 'IsPortListening -portNumber 80' } +end + +describe port('invalid') do + it { should_not be_listening } +end + +describe port(80) do + it { should be_listening.with("tcp") } + its(:command) { should eq "IsPortListening -portNumber 80 -protocol 'tcp'" } +end + +describe port(123) do + it { should be_listening.with("udp") } + its(:command) { should eq "IsPortListening -portNumber 123 -protocol 'udp'" } +end + +describe port(80) do + it { + expect { + should be_listening.with('not implemented') + }.to raise_error(ArgumentError, %r/\A`be_listening` matcher doesn\'t support/) + } +end diff --git a/spec/windows/user_spec.rb b/spec/windows/user_spec.rb new file mode 100644 index 00000000..f454e0cf --- /dev/null +++ b/spec/windows/user_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +include Serverspec::Helper::Cmd +include Serverspec::Helper::Windows + +describe user('test.user') do + it { should exist } + its(:command) { should eq "(FindUser -userName 'test.user') -ne $null" } +end + +describe user('test.domain\test.user') do + it { should exist } + its(:command) { should eq "(FindUser -userName 'test.user' -domain 'test.domain') -ne $null" } +end + +describe user('invalid-user') do + it { should_not exist } +end + +describe user('test.user') do + it { should belong_to_group 'test.group' } + its(:command) { should eq "(FindUserGroup -userName 'test.user' -groupName 'test.group') -ne $null" } +end + +describe user('test.user.domain\test.user') do + it { should belong_to_group 'test.group.domain\test.group' } + its(:command) { should eq "(FindUserGroup -userName 'test.user' -userDomain 'test.user.domain' -groupName 'test.group' -groupDomain 'test.group.domain') -ne $null" } +end + +describe user('test.user') do + it { should_not belong_to_group 'invalid-group' } +end + +describe user('test.user') do + it "should raise error if command is not supported" do + { + :have_uid => [nil], + :have_login_shell => [nil], + :have_authorized_key => [nil], + }.each do |method, args| + expect { should self.send(method, *args) }.to raise_error Serverspec::Commands::Windows::NotSupportedError + end + end +end