Puppet Labs: The WSUS client module: a beginner's guide

Datetime:2016-08-23 03:27:58          Topic:          Share

The University of Saskatchewan has a heterogeneous environment of several thousand Windows and *nix servers. In order to help manage this environment, I have been tasked with starting some configuration management with Puppet on Windows servers. My first endeavour was to examine the wsus_client module and how I could achieve what we were already doing with native Microsoft-provided tools such as Group Policy and PowerShell scripts.

We have very specific business needs around the timing and automation of Windows updates. Many organizations are in the same boat with a very limited window in which they can do patching of their Microsoft products. To accomplish this task, we use Group Policy Objects (GPOs) and a configuration PowerShell script. The script configures WSUS for very specific timeframes in which to apply updates.

But what if you want to automate this in a more visible way and track any changes? Certainly Microsoft has provided tools to audit and track changes to locked-down GPOs through products you can license. What of the script that flips those bits? Do its changes get tracked and audited? For this business case you still need the script, unless you are going to make manual changes to GPOs every patch night. This also enables you to move further towards dealing with your infrastructure as code. The Puppet wsus_client module allows for a more streamlined and auditable way to accomplish the same thing.

Creating this in Puppet was an iterative process. As I learned new things about Puppet, I improved my code. Firstly, I had installed the Puppet wsus_client module from https://forge.puppet.com/puppetlabs/wsus_client . I used Code Management configured to pull my code from Git. You can read more about Code Management at https://docs.puppet.com/pe/latest/code_mgr.html .

I began by trying to replicate my PowerShell code in Puppet. My first iteration was to write my own Puppet module to help me deploy my original PowerShell script in a file resource, in combination with external facts.

class ::osupdate {
    case $::osfamily {
      'Windows' : {
          file { 'C:/ProgramData/PuppetLabs/facter/facts.d/winpatchtime.ps1' :
                source => 'puppet:///modules/platforms/winpatchtime.ps1',
          }
      default :{}
    }  
}

There was one problem with this: I would have to wait for two Puppet runs to occur. The first to place the external fact file in the directory, the second to run the external fact code.

Enter the facts.d directory within my module: By moving my external facts to the facts.d directory inside my module, I no longer had to wait for two Puppet runs to see (and make use of my fact in a manifest). It would immediately be copied and executed on the first Puppet run on my nodes. Additionally, it would be copied to EVERY node making use of that module. This created a problem, as our environment is heterogeneous, with Linux and Windows nodes. Other *nix admins started seeing warnings on Linux nodes: “/var/opt/lib/pe-puppet/facts.d/powershellscript.ps1 was parsed but returned an empty data set”.

It was suggested to me that I could restrict code execution to a single type of operating system, namely Windows if I wrote a custom Ruby fact. It took some time to gain the rudimentary Ruby knowledge but the result was worth it. Now I can use my custom fact in /mymodule/lib/facter and have it run ONLY on Windows machines. No more warning messages for other administrators.

Facter.add('patchtime') do
  confine :kernel => 'windows'
  dname = "unknown"
  begin
    if RUBY_PLATFORM.downcase.include?('mswin') or         RUBY_PLATFORM.downcase.include?('mingw32')
        dname = Facter::Util::Resolution.exec('C:\Windows\system32\WindowsPowerShell\v1.0\powershell.exe -NonInteractive -NoProfile -ExecutionPolicy Bypass -command (Get-Itemproperty \"HKLM:SOFTWARE\Microsoft\Windows\CurrentVersion\Group Policy\State\Machine\\").\'Distinguished-Name\'.toString()')

        begin
          if dname.include?('10')
            ptime = '22'
          elsif dname.include?('11')
            ptime = '23'
          else
            end
            rescue
        end
    end
    rescue    
  end
  setcode do
    ptime
  end
end

Now that I had my custom facts available, I could turn my module manifest into a simple series of case statements.

class ::osupdate {
    $day = strftime('%A')
    $hour  = strftime('%-H') + 1
    case $::osfamily {
      'Windows' : {
#uses logic from second ruby fact code to determine if it is patch day 
          case $::patchtoday {
              '1' : {
                class { 'wsus_client':
                    auto_update_option                  => 'Scheduled',
                    detection_frequency_hours           => 1,
                    no_auto_reboot_with_logged_on_users => false,
                    server_url                          => 'http://wsus.address.com',
                    target_group                        => 'ServerUpdates',
                }
#uses logic from patchtime.rb above
                case $::patchtime {
                        '10' : {
                                class { 'wsus_client':
                                scheduled_install_day  => $::patchdate,
                                scheduled_install_hour => 22,
                                }
                            }
                        '11' : {
                                class { 'wsus_client':
                                scheduled_install_day  => $::patchdate,
                                scheduled_install_hour => 23,
                                }
                            }
                        default : {}
                        }
                }
          '0' : {
              class { 'wsus_client':
                auto_update_option                  => 'AutoNotify',
                detection_frequency_hours           => 22,
                no_auto_reboot_with_logged_on_users => true,
                server_url                          => 'http://wsus.address.com',
                target_group                        => 'Serverupdates',
              }
          }
          'new' : {
              class { 'wsus_client' :
                auto_update_option                  => 'Scheduled',
                detection_frequency_hours           => 1,
                no_auto_reboot_with_logged_on_users => false,
                scheduled_install_hour              => $hour,
                scheduled_install_day               => $day,
                server_url                          => 'http://wsus.address.com',
                target_group                        => 'ServerUpdates',
              }
          }
          default : {}
          }           
      }
      default :{}
    } 
}

Ugly, isn’t it? Well for a first iteration manifest it got the job done. I then discovered Hiera. Hiera in our environment is where all business data should live, so I moved the variables from my manifest into Hiera. For example my ../osfamily/windows.yaml contains:

osupdate::server_url: 'http://wsus.address.com'
    osupdate::target_group: 'ServerUpdates'
    osupdate::scheduled_install_hour: "%{::patchtime}"

Now all my custom fact-based case statements can just flip which variables are used in a single class declaration. This makes the code prettier and far easier to read, which enables other administrators to quickly understand it. It also removed the need for a second custom fact determining what date to patch. Patching in test environments takes place on a different date than production. This is easy to manage in Hiera per environment, rather than using a custom fact.

During the time I was creating this module, I had a third case come to mind. What about a newly installed Windows server? This machine might be greatly out of date and need immediate patching. Since my module is performing all tasks related to patching, it seems a good place for this functionality.

Given that forcing a machine to patch via an exec resource and some PowerShell code might make the exec run on too long and greatly extend the time a Puppet run takes, I chose to use a scheduled task to run my PowerShell code. In fact, I tried the exec method first, and found it fraught with issues. Windows scheduled tasks can be Puppet-managed, and attempts to restart an already running task do not run. My Winupdate.ps1 script also involved reboots, so I set the scheduled task to run a $delay number of minutes after the Puppet run. This means on the day of install, a machine can be completely patched even with several reboots.

class ::osupdate {
    $delay = hiera('osupdate::delay')
    $schtime = inline_template("<%=(Time.now + ${delay}).strftime('%-H:%M')%>")
    case $::osfamily {
      'Windows' : {
          $updateserver = hiera('osupdate::server_url')
          $updategroups = hiera('osupdate::target_group')
          case $::patchtoday {
            'yes' : {
                $installday = hiera('osupdate::scheduled_install_day')
                $installhour = hiera('osupdate::scheduled_install_hour')
                $updateoption = hiera('osupdate::scheduled')
                $detectinterval = hiera('osupdate::hourly_detection')
                $rebootwithusers = hiera('osupdate::reboot_with_logon')
                $scheduletask = hiera('osupdate::no_task')
                $scheduletaskenable = hiera('osupdate::task_disabled')
                $script = hiera('osupdate::no_file')
                $autoreboot = hiera('osupdate::autoreboot')
                }
            'new' : {
                $updateoption = hiera('osupdate::auto_install')
                $detectinterval = hiera('osupdate::hourly_detection')
                $rebootwithusers = hiera('osupdate::reboot_with_logon')
                $scheduletask = hiera('osupdate::task_exists')
                $scheduletaskenable = hiera('osupdate::task_enabled')
                $script = hiera('osupdate::file_exists')
                }
            default : {
                $updateoption = hiera('osupdate::auto_notify')
                $detectinterval = hiera('osupdate::default_detection')
                $rebootwithusers = hiera('osupdate::no_reboot_with_logon')
                $scheduletask = hiera('osupdate::no_task')
                $scheduletaskenable = hiera('osupdate::task_disabled')
                $script = hiera('osupdate::no_file')
                $autoreboot = hiera('osupdate::noautoreboot')
            }
        }
        class { 'wsus_client' :
            server_url                          => $updateserver,
            target_group                        => $updategroups,
            auto_update_option                  => $updateoption,
            scheduled_install_day               => $installday,
            scheduled_install_hour              => $installhour,
            detection_frequency_hours           => $detectinterval,
            no_auto_reboot_with_logged_on_users => $rebootwithusers,
            purge_values                        => true
            }
        scheduled_task { 'update' :
            ensure    => $scheduletask,
            enabled   => $scheduletaskenable,
            require   => File['C:/WSUSupdate.ps1'],
            command   => 'C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe',
            arguments => '-Executionpolicy Bypass -Command C:/WSUSupdate.ps1',
            trigger   => {
                schedule   => daily,
                start_time => $schtime,
                }
            }
        file { 'C:/WSUSupdate.ps1' :
            ensure => $script,
            source => 'puppet:///modules/osupdate/WSUSupdate.ps1',
            }
        registry_value { 'HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU\AlwaysAutoRebootAtScheduledTime':
            ensure => present,
            type   => dword,
            data   => $autoreboot,
            }
        registry_value { 'HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU\AlwaysAutoRebootAtScheduledTimeMinutes':
            ensure => present,
            type   => dword,
            data   => 15,
            }
        }
    default : {}
}
}

Now I can make changes as to how updates are being performed just by changing the values in Hiera. There may be better ways to get this done in Puppet, as I’m limited by my custom fact. I’m sure there’s yet another iteration of this code that will make it even more scalable.

Note: There are also future fixes coming to the wsus_client module for the AlwaysAutoRebootAt ScheduledTime and AlwaysAutorebootAtScheduledTimeMinutes registry entries that are being managed in the manifest above to have parity with GPOs for Windows 8/Server 2012 and above. You will need to install the registry module to your Puppet master to be able to make use of the code.

Another important thing to note is that if you choose to manage the WSUS client with Puppet, you should not manage it via GPOs. Doing so has unexpected and unplanned outcomes.

Andrew Ball is a systems administrator at the University of Saskatchewan.