Skip to content

Instantly share code, notes, and snippets.

@coateds
Created January 16, 2018 19:09
Show Gist options
  • Save coateds/56b49b13e51a6352175ac4f99b3517a2 to your computer and use it in GitHub Desktop.
Save coateds/56b49b13e51a6352175ac4f99b3517a2 to your computer and use it in GitHub Desktop.
Tutorial 4: Windows resources and tests

Tutorial 4: Windows resources and tests

In this section there likely will not be any large new concepts. Rather it will be a collection of recipes and code I have collected into one location.

In an earlier tutorial, I suggested that I would not provied step-by-step instruction on resources because they are very well documented. In addition, in many cases it is easier to learn from examples than by explanation. Here, I will provide a lot of examples!

However, I do think there is value in reviewing the use of PowerShell both in Recipes and in Testing. It turns out that you can use PowerShell in both recipes and InSpec integration tests. I am putting most of these explanations in as comments inline to the files themselves, then they are copied and posted below. Some highlights include:

  • Command outputs often return with a "\r\n" which is a carriage return/line feed. The .chop method eliminates those characters.
  • The to_s method converts something to a string. I am still learning where this is actually necessary...
  • \'#{node['windows-tweaks']['new-computername']}\' There is a lot going on in this one line.
    • The #{...} syntax will expand an attribute (and possibly a variable??) when used inside double quotes.
    • The \' combination is an escape sequence to force the literal use of the single quote in the PowerShell command.

The same PowerShell scripts/one-liners can be used in a guard for idempotence as for an Integration test.

windows-installation-recipes (cookbook)

The library cookbook, windows-installation-recipes starts with the following five recipes. This cookbook has been under constant revision and can be found here: https://github.com/coateds/windows-installation-recipes. I am going to try and capture a snapshot here. If you go look at it after this writing, it will likely be different as I get better at understanding this stuff.

  • access-rdp
  • active-directory
  • install-iis-serverinfo
  • install-packages
  • windows-tweaks

windows-tweaks (recipe)

This is a smorgasbord of items one might want for customizing a Windows Server 2012 installation. If you find the start up of Server Manager at logon to be highly annoying, it is just a scheduled task that can be disabled.

For some reason, the PowerShell icon disappears from the task bar when installed via the msu_package.

So far, I have not found a way to specify a name for a Windows HyperV differenceing disk test kitchen generated new VM. This recipe uses PowerShell to rename a computer if the attribute is anything but default['windows-tweaks']['new-computername'] = 'no-new-name'. Note the PowerShell guard in the recipe resouce that makes it idempotent.

recipe::windows-tweaks

# I usually place scripts in the following directory, so why not create it right away
directory 'C:\scripts'

# This disables the scheduled task that opens Server Manager at everyone's logon
windows_task '\Microsoft\Windows\Server Manager\ServerManager' do
  action :disable
end

# Sometimes the PowerShell 5.1 installation wipes out the icons on the task bar
# This puts a link on the desktop as a short term fix
# Remember that the cookbook_file resource copies a file from the cookbook to the client
# It does not create it
cookbook_file 'C:\Users\Public\Desktop\Windows PowerShell.lnk' do
  source 'Windows PowerShell.lnk'
end

# This is an example of a powershell_script resource. In this case the computer is being renamed
# However, the not_if guard_interpreter prevents the resource from running under certain circumstances
# Either:
# 1. The computer is already named as desired (the environment var matches the attribute)
#    This makes the resource idempotent
# 2. The attribute has a set value 'no-new-name' that allows the attribute to exist without causing a rename
# Note that the not_if guard also runs PowerShell, but with slightly different rules. Observe the extra escape characters ('\')
powershell_script 'rename-computer' do
  code "Rename-Computer -NewName #{node['windows-tweaks']['new-computername']}"
  not_if "($env:COMPUTERNAME -eq \'#{node['windows-tweaks']['new-computername']}\') -or (\'#{node['windows-tweaks']['new-computername']}\' -eq 'no-new-name')"
end

# Unit Tests
# These tests tend to be case sensitive between test and resource
# ('c:\scripts' will not work)
it 'creates the directory c:\scripts' do
  expect(chef_run).to create_directory('C:\scripts')
end

it 'disables the task \Microsoft\Windows\Server Manager\ServerManager' do
  expect(chef_run).to disable_windows_task('\Microsoft\Windows\Server Manager\ServerManager')
end

it 'creates the cookbook file PowerShell/lnk' do
  expect(chef_run).to create_cookbook_file('C:\Users\Public\Desktop\Windows PowerShell.lnk')
end

# Integration Tests
describe directory('C:\scripts') do
  it { should exist }
end

describe file('C:\Users\Public\Desktop\Windows PowerShell.lnk') do
  it { should exist }
end

describe windows_task ('\Microsoft\Windows\Server Manager\ServerManager') do
  it { should be_disabled }
end

computernamescript = <<-EOH
  $env:COMPUTERNAME
EOH

# The output of the script is "SERVERNAME\r\n"
# The .chop is necessary to trim the carriage return/line feed from the output
describe powershell(computernamescript) do
  its('stdout.chop') { should eq 'SERVERNAME' }
end

access-rdp (recipe)

Examples for working with the Registry both in recipes and in InSpec tests.

Also, there is a more complex PowerShell example. Likely this could be refactored to use a guard similar to that in windows-tweaks, but it is rather instructive the way it is for now.

recipes::access-rdp

case node['my_windows_rdp']['AllowConnections'].to_s
when 'yes'
  allow = 0
when 'no'
  allow = 1
end

# Change the registry if needed
registry_key 'HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Terminal Server' do
  values [{
    name: 'fDenyTSConnections',
    type: :dword,
    data: allow,
  }]
  action :create
end

# Set the Reg data var according to the Attribute
case node['my_windows_rdp']['AllowOnlyNLA'].to_s
when 'yes'
  nla_only = 1
when 'no'
  nla_only = 0
end

# Change the registry if needed
registry_key 'HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp' do
  values [{
    name: 'UserAuthentication',
    type: :dword,
    data: nla_only,
  }]
  action :create
end

# PowerShell script to determine if the firewall rule is enabled
# Set a ruby variable to a string True/False
fw_rule_enabled_script = <<-EOH
  $RDFwRuleEnabled = $true
  ForEach ($Item in Get-NetFirewallRule -DisplayGroup "Remote Desktop"){If ($Item.Enabled -eq "False") {$RDFwRuleEnabled = $false}}
  return $RDFwRuleEnabled
EOH
cmd = powershell_out(fw_rule_enabled_script)
fw_rule_enabled = cmd.stdout.chop.to_s

# This is the desired firewall rule action from attributes
fw_should_be_enabled = node['my_windows_rdp']['ConfigureFirewall'].to_s

# This uses Ruby code to decide what action to take and a powershell_script resouce to take action
# No the most Chef-y way to do it, but much less confusing than my attempts to do it with guards
if fw_rule_enabled == 'False' && fw_should_be_enabled == 'yes'
  puts 'FW rule should be enabled, and is not. Enable it'
  powershell_script 'Enable FW Rule' do
    code 'Enable-NetFirewallRule -DisplayGroup "Remote Desktop"'
  end
elsif fw_rule_enabled == 'True' && fw_should_be_enabled == 'no'
  puts 'FW rule should NOT be enabled, and is. Disable it'
  powershell_script 'Enable FW Rule' do
    code 'Disable-NetFirewallRule -DisplayGroup "Remote Desktop"'
  end
else
  puts 'No change to FW rule required'
end

-no unit tests

# Allow Access
describe registry_key ({
  hive: 'HKEY_LOCAL_MACHINE',
  key: 'SYSTEM\CurrentControlSet\Control\Terminal Server'
}) do
  its('fDenyTSConnections') { should eq 0 }
end

# NLA Only
describe registry_key ({
  hive: 'HKEY_LOCAL_MACHINE',
  key: 'SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp'
}) do
  its('UserAuthentication') { should eq 1 }
end

# Is the firewall rule enabled for all profiles?
fw_rule_enabled_script = <<-EOH
  $RDFwRuleEnabled = $true
  ForEach ($Item in Get-NetFirewallRule -DisplayGroup "Remote Desktop"){If ($Item.Enabled -eq "False") {$RDFwRuleEnabled = $false}}
  return $RDFwRuleEnabled
EOH

# The .chop is necessary to trim the carriage return/line feed from the output
describe powershell(fw_rule_enabled_script) do
  its('stdout.chop') { should eq 'True' }
end

install-packages (recipe)

The concept of this recipe was introduced in the first tutorial. With this iteration, the PowerShell (msu) installation includes a PowerShell Guard to prevent it from running if already at Version 5.1. This was to make the recipe more compatible with Server 2016 as that OS already includes 5.1, but was not installed with the msu package so the built in idempotence does not apply.

recipes::install-packages

include_recipe 'chocolatey::default'

# This will run an upgrade for images that include an old version
if node['install-packages']['upgrade-chocolatey'].to_s == 'y'
  chocolatey_package 'chocolatey' do
    action :upgrade
  end
end

# PS guard introduced for compatibility with 2016
if node['install-packages']['powershell51'].to_s == 'y'
  cookbook_file 'Win8.1AndW2K12R2-KB3191564-x64.msu' do
    source 'Win8.1AndW2K12R2-KB3191564-x64.msu'
    guard_interpreter :powershell_script
    not_if "$PSVersionTable.PSVersion.Major.ToString()+'.'+$PSVersionTable.PSVersion.Minor.ToString() -eq '5.1'"
  end

  msu_package 'Win8.1AndW2K12R2-KB3191564-x64.msu' do
    source 'Win8.1AndW2K12R2-KB3191564-x64.msu'
    action :install
    notifies :reboot_now, 'reboot[restart-computer]', :delayed
    guard_interpreter :powershell_script
    not_if "$PSVersionTable.PSVersion.Major.ToString()+'.'+$PSVersionTable.PSVersion.Minor.ToString() -eq '5.1'"
  end
end

# This is old code for the Windows Update.
# The next resource block is easier and more effective,
# but this is still a useful example.
# For 2012R2 this resource block MUST occur after the WMF 5.1 installation AND reboot
# Add-WUServiceManager -ServiceID 7971f918-a847-4430-9279-4a52d1efe18d
# powershell_script 'installpswindowsupdate' do
#   code <<-EOH
#   Install-PackageProvider -Name "NuGet" -Force
#   Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
#   Install-Module -name PSWindowsUpdate -Force
#   EOH
#   not_if "(Get-Module -ListAvailable -Name PSWindowsUpdate).Name -eq 'PSWindowsUpdate'"
# end

# Running Update-Module just adds the new version along side the current
# This messes up existing tests
# For now, uninstall old first then install the new
if node['install-packages']['PSWindowsUpdate'].to_s == 'y'
powershell_script 'Uninstall PSWindowsUpdate' do
  code "Uninstall-Module -Name PSWindowsUpdate"
  not_if "(Get-Module -ListAvailable -Name PSWindowsUpdate).version.major -eq 2"
end

powershell_package 'PSWindowsUpdate'

# chocolatey_package 'visualstudiocode'
# if node['install-packages']['vscode'].to_s == 'y'
#   chocolatey_package 'visualstudiocode'
# end
# Alternative (and favored by cookstyle) syntax for a simple conditional
# chocolatey_package 'visualstudiocode' if node['install-packages']['vscode'].to_s == 'y'

# Chocolatey attempts to install DotNet4.5.2 with VSCode
# currently (Dec 2017) This fails on 2012R2
# This does work for 2016
# attempting to explicitly call this install ahead of VSCode
# howwever, the DotNet install calls for a reboot
if node['install-packages']['vscode'].to_s == 'y'
  # chocolatey_package 'dotnet4.5.2'
  chocolatey_package 'visualstudiocode' do
    action :install
    only_if { node[:platform_version]  == '10.0.14393' }
  end
end

# git will not be available to a logged on user (logout/logon to use it)
if node['install-packages']['git'].to_s == 'y'
  chocolatey_package 'git' do
    options '--params /GitAndUnixToolsOnPath'
  end
end

chocolatey_package 'chefdk' if node['install-packages']['chefdk'].to_s == 'y'
chocolatey_package 'putty' if node['install-packages']['putty'].to_s == 'y'
chocolatey_package 'sysinternals' if node['install-packages']['sysinternals'].to_s == 'y'
chocolatey_package 'curl' if node['install-packages']['curl'].to_s == 'y'
chocolatey_package 'poshgit' if node['install-packages']['poshgit'].to_s == 'y'
chocolatey_package 'pester' if node['install-packages']['pester'].to_s == 'y'
chocolatey_package 'rdcman' if node['install-packages']['rdcman'].to_s == 'y'
chocolatey_package 'slack' if node['install-packages']['slack'].to_s == 'y'
chocolatey_package 'azurestorageexplorer' if node['install-packages']['azstorexplorer'].to_s == 'y'

if node['install-packages']['winazpowershell'].to_s == 'y'
  chocolatey_package 'windowsazurepowershell' do
    action :install
    notifies :reboot_now, 'reboot[restart-computer]', :delayed
  end
end

if node['install-packages']['requestreboot'].to_s == 'y'
  reboot 'restart-computer' do
    action :request_reboot
    ignore_failure
  end
end

reboot 'restart-computer' do
  action :nothing
end



# ChefSpec Tests
require 'spec_helper'

describe 'windows-installation-recipes::install-packages' do
  context 'When all attributes are default, on a Windows 2012 R2' do
    let(:chef_run) do
      # for a complete list of available platforms and versions see:
      # https://github.com/customink/fauxhai/blob/master/PLATFORMS.md
      runner = ChefSpec::ServerRunner.new(platform: 'windows', version: '2012R2')
      runner.converge(described_recipe)
    end

    # package installation section

    # NOTE: There is no unit test for the default recipe of chocolatey installation of Chocolatey

    # Select and test for the packages desired in a particular build

    # The ChefSpec/RSpec unit testing process will respect Ruby code and attributes
    # For instance the install-packages recipe will be written to install
    # packages depending on whether an attribute is set to 'y'. The ChefSpec/RSpec
    # process will pass or fail depending on how this attribute is set and consumed
    # in a conditional within the recipe

    # Will pass if the attribute is set
    it 'installs a package' do
      expect(chef_run).to install_chocolatey_package('visualstudiocode')
    end

    # Here is an example with options
    it 'installs a package with options' do
      expect(chef_run).to install_chocolatey_package('git').with(
        options: '--params /GitAndUnixToolsOnPath'
      )
    end

    # Test for a list of apps
    # Each item will be treated as a single test
    # %w(chefdk putty sysinternals curl poshgit pester rdcman slack azurestorageexplorer).each do |item|
    #   it 'installs a package' do
    #     expect(chef_run).to install_chocolatey_package(item)
    #   end
    # end

    # The following installation will require a reboot
    it 'installs a package' do
      expect(chef_run).to install_chocolatey_package('windowsazurepowershell')
      # ref:https://github.com/chefspec/chefspec for next line. Does not work, but I think is close
      # another ref: https://chefspec.github.io/chefspec/
      # expect(chef_run).to notify('reboot[restart-computer]').to(:reboot_now).delayed
    end

    # I believe what this expects is to reboot the computer at the end of the client run
    # regardless of what other actions are taken
    # NOTE: 'restart-computer' must match exactly to the name of the reboot block
    # with the action :request_reboot
    it 'runs a request_reboot' do
      expect(chef_run).to request_reboot('restart-computer')
    end

    ### Use these together ###
    # These two blocks will test with the expectation of an explicit reboot
    it 'creates the file Win8.1AndW2K12R2-KB3191564-x64.msu in cache' do
      expect(chef_run).to create_cookbook_file('Win8.1AndW2K12R2-KB3191564-x64.msu')
    end

    it 'installs a msu_package Win8.1AndW2K12R2-KB3191564-x64.msu' do
      expect(chef_run).to install_msu_package('Win8.1AndW2K12R2-KB3191564-x64.msu')
    end

    # Add this block if the reboot should be called via notifies
    it 'reboots after install' do
      resourceps = chef_run.msu_package('Win8.1AndW2K12R2-KB3191564-x64.msu')
      expect(resourceps).to notify('reboot[restart-computer]').to(:reboot_now).delayed
    end

    # Use this block instead if the reboot should be called out explicitly
    it 'runs a request_reboot' do
      expect(chef_run).to request_reboot('restart-computer')
    end
    ### /Use these together ###

    # However, this reboot scenario does not cover the intent to reboot ONLY if a particular
    # resource block is run. An attempt to make this work with a notifies option in the chocolatey_package
    # resource of the recipe revealed this not to be a good strategy. Best seems to make it run
    # as a conditional to an attribute and explicitly include it here when needed

    # This is now a back and forth... There does seem to be an test methodology for notifies...reboot
    ### Use these together ###
    # These two blocks test that the package is installed
    # followed by a reboot
    it 'installs a package' do
      expect(chef_run).to install_chocolatey_package('windowsazurepowershell')
    end

    it 'reboots after install' do
      resource = chef_run.chocolatey_package('windowsazurepowershell')
      expect(resource).to notify('reboot[restart-computer]').to(:reboot_now).delayed
    end
    ### /Use these together ###

    # /package installation section

    it 'converges successfully' do
      expect { chef_run }.to_not raise_error
    end
  end
end

# Inspec Tests

# This is in transition from an older way that used match to a new way
# that uses eq for a set number of string characters.

# Testing for installed apps
# Test for PowerShell
describe command("$PSVersionTable.PSVersion.Major.ToString()+'.'+$PSVersionTable.PSVersion.Minor.ToString()") do
  its('exit_status') { should eq 0 }
  its('stdout.chop') { should eq '5.1' }
end

# PS Windows Update module
describe command('(Get-Module -ListAvailable -Name PSWindowsUpdate).Name') do
  its('exit_status') { should eq 0 }
  its('stdout.chop') { should eq 'PSWindowsUpdate' }
end

# Test for a list of apps
# Each item will be treated as a single test
# %w(slack git putty curl).each do |item|
#   describe package(item) do
#     it { should be_installed }
#   end
# end

describe command('choco -v') do
  its('exit_status') { should eq 0 }
  its('stdout') { should match('0.10.8') }
end

# Most of the following describe blocks will run choco list...
# and capture/test its output
describe command('choco list AzureStorageExplorer --exact --local-only --limit-output') do
  its('exit_status') { should eq 0 }
  its('stdout') { should match('AzureStorageExplorer|') }
end

describe command('choco list chefdk --exact --local-only --limit-output') do
  its('exit_status') { should eq 0 }
  its('stdout') { should match('chefdk|') }
end

# This version replaced by the next resource block which should be more exact.
describe command('choco list git --exact --local-only --limit-output') do
  its('exit_status') { should eq 0 }
  its('stdout') { should match('git|') }
end

# Here the cli for Chocolatey is wrapped in PowerShell, in order to use the .substring command for just the first three chrs
describe command('(invoke-expression "choco list git --exact --local-only --limit-output").substring(0,3)') do
  its('exit_status') { should eq 0 }
  its('stdout.chop') { should eq 'git' }
end

# Here the cli for Chocolatey is wrapped in PowerShell, in order to use the .substring command for just the first sixteen chrs
describe command('(invoke-expression "choco list visualstudiocode --exact --local-only --limit-output").substring(0,16)') do
  its('exit_status') { should eq 0 }
  its('stdout.chop') { should eq 'visualstudiocode' }
end

describe command('choco list sysinternals --exact --local-only --limit-output') do
  its('exit_status') { should eq 0 }
  its('stdout') { should match('sysinternals|') }
end

describe command('choco list poshgit --exact --local-only --limit-output') do
  its('exit_status') { should eq 0 }
  its('stdout') { should match('poshgit|') }
end

describe command('choco list pester --exact --local-only --limit-output') do
  its('exit_status') { should eq 0 }
  its('stdout') { should match('pester|') }
end

describe command('choco list rdcman --exact --local-only --limit-output') do
  its('exit_status') { should eq 0 }
  its('stdout') { should match('rdcman|') }
end

describe command('choco list putty --exact --local-only --limit-output') do
  its('exit_status') { should eq 0 }
  its('stdout') { should match('putty|') }
end

describe command('choco list curl --exact --local-only --limit-output') do
  its('exit_status') { should eq 0 }
  its('stdout') { should match('curl|') }
end

describe command('choco list windowsazurepowershell --exact --local-only --limit-output') do
  its('exit_status') { should eq 0 }
  its('stdout') { should match('windowsazurepowershell|') }
end

# This one package can be tested with the generic package test
describe package('slack') do
  it { should be_installed }
end

# /package installation section

active-directory (recipe)

Simply utilizes a supermarket cookbook to join a computer to a domain. I have plans to do more with this. For now, nothing really new.

recipes::active-directory'

# Join coatelab.com domain
# The resource name in the first line does not seem to matter
windows_ad_computer 'This_Computer' do
  action :join
  domain_pass 'XXXXXXXX'
  domain_user 'Administrator'
  domain_name 'mylab.com'
  # domain_name node['active-directory']['domain-name']
  restart true
end

-no unit tests

InSpec Tests
#### active-directory ####
domain_member_script = <<-EOH
(Get-WmiObject -Class Win32_ComputerSystem).Domain
EOH

describe powershell(domain_member_script) do
its('stdout.chop') { should eq 'mylab.com' }
end
#### /active-directory ####

install-iis-info (recipe)

This is a windows version of a linux recipe from an earlier tutorial. It installs IIS, gathers information from Ohai/Fauxhai and puts it in a template html page. The template page is more complete, and also includes values that are generated from PowerShell in the recipe, assigned to an attribute and then consumed by the template. In a production environment, with this recipe executed every 15 min, this would become a dynamic 'BGInfo' kind of webpage placed in the same location on the file system of a large list of servers.

recipes::install-iis-serverinfo

-This will be refactored and extended in a later tutorial

#
# Cookbook:: windows-installation-recipes
# Recipe:: install-iis-serverinfo
#
# Copyright:: 2017, The Authors, All Rights Reserved.
# Installs the Web Server Feature/Role
# powershell_script 'Install IIS' do
#   code 'Add-WindowsFeature Web-Server'
#   guard_interpreter :powershell_script
#   not_if '(Get-WindowsFeature -Name Web-Server).Installed'
# end

# This is another way to install a feature/role. It is in the Windows cookbook
# There are three ways it can install dism, svrmgrcli and powershell
# if not specified, it will choose it's preferred method
# Docs here: https://github.com/chef-cookbooks/windows
windows_feature 'IIS-WebServerRole' do
  action :install
  install_method :windows_feature_dism
end
# Look in https://github.com/chef-cookbooks/windows/blob/master/libraries/matchers.rb
# for ChefSpec unit test syntax

# This is generally not necessary after install, but useful to prevent configuration drift.
service 'w3svc' do
  action [:enable, :start]
end

node.default['recipe_var'] = 'Gooberlicious'

node.default['last_update'] = powershell_out('(get-hotfix | sort installedon | select -last 1).InstalledOn').stdout.chop.to_s

# Before using the template, it must be created
# chef generate template default
# Create the file templates\default.htm.erb
# This tends to be case sensitive
# Templates can use ohai automatic attributes for display
#   This might be used to generate a kind of Chef generated BGInfo
template 'c:/inetpub/wwwroot/default.htm' do
  source 'default.htm.erb'
end

-no unit tests

#### install-iis-serverinfo ####
describe port(80) do
  it { should be_listening }
end

describe windows_feature('IIS-WebServerRole') do
  it { should be_installed }
end

describe service('w3svc') do
  it { should be_installed }
  it { should be_enabled }
  it { should be_running }
end

describe command('(Invoke-WebRequest localhost).StatusCode') do
  its('stdout') { should match '200' }
end
#### /install-iis-serverinfo ####

templates\default.htm.erb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment