Recently, I’ve been on a run of trying out different combinations of well-known automation tools to dynamically provision and tear down some dev test infrastructure that I need for a side project. The results of which I put together into a two-part tutorial called: Provisioning Windows Server 2016 with Vagrant, Hyper-V and Chef (Part 1 – Part 2).

I chose Chef to do the configuration management piece because they’re known for being quite forward in their support for Windows platforms. If you’re involved in automation I think it’s important to be comfortable with all mainstream server operating systems, so I tend not to choose tools that are only useful on one of the platforms if I can avoid it. Most configuration management platforms were created on Linux, but Chef is at least vocal about wanting to treat Windows as a first class citizen and that did influence my choice.

I had a great time with the Windows cookbook for Chef, which is officially supported by Chef themselves, but I did encounter issues with some of the other community-supported cookbooks when I had to venture outside what the officially supported one covered. This is probably because support for Windows Server 2016 has been a little slow in coming for those other cookbooks, but it did mean I had to start looking around for other contenders.

Enter PowerShell Desired State Configuration

PowerShell Desired State Configuration (DSC) is a domain-specific language for configuring and managing systems that is native to PowerShell since version 4.0. It performs the same job as the Chef DSL described in my Vagrant tutorial series, only its agent is part of the Windows operating system rather than something that you need to install separately. Although Microsoft has developed a DSC agent for Linux, there are many more resources for working with Windows currently, so it finds itself in the opposite position of most other configuration management systems.

A file server DSC configuration

Below is the entire DSC configuration I use for my file server:

[DSCLocalConfigurationManager()]
Configuration LcmConfig
{
    Node "localhost"
    {
        Settings
        {
            RefreshMode = "Push"
            ConfigurationMode = "ApplyOnly"
            RebootNodeIfNeeded = $true
        }
    }
} 

Configuration CifsSyncTestServer {

    Import-DscResource -ModuleName PSDesiredStateConfiguration, xComputerManagement, xNetworking, xPendingReboot, xSmbShare

    Node $AllNodes.Where{$_.Role -contains "FileServer"}.NodeName {

        xDnsServerAddress SetDnsServers {
            InterfaceAlias = "Ethernet"
            AddressFamily = "IPv4"
            Address = $ConfigurationData.Domain.DnsServers
        }

        xComputer DomainJoin {
            Name = "localhost"
            DomainName = $ConfigurationData.Domain.Name
            Credential = New-Object -TypeName System.Management.Automation.PSCredential(
                            $ConfigurationData.Domain.AdministratorUser,
                            (ConvertTo-SecureString -String $ConfigurationData.Domain.AdministratorPassword -AsPlainText -Force)
                         )
            DependsOn = "[xDnsServerAddress]SetDnsServers"
        }

        WindowsFeature FileServices {
            Name = "File-Services"
            Credential = New-Object -TypeName System.Management.Automation.PSCredential(
                            $Node.LocalAdministratorUser,
                            (ConvertTo-SecureString -String $Node.LocalAdministratorPassword -AsPlainText -Force)
                         )
            Ensure = "Present"
        }

        File ShareDirectory {
            DestinationPath = $Node.SharePath
            Type = "Directory"
            Ensure = "Present"
        }

        xSmbShare Share {
            Name = "CifsShare"
            Path = $Node.SharePath
            Ensure = "Present"
            ReadAccess = $Node.ShareReadAccess
            FullAccess = $Node.ShareFullAccess
            DependsOn = "[WindowsFeature]FileServices", "[File]ShareDirectory"
        }

        xPendingReboot Reboot {
            Name = "Reboot"
            DependsOn = "[xComputer]DomainJoin"
        }
    }
}

There are actually two configurations here, but let’s skip over the first one (LcmConfig) for now. It will make more sense to go through the second one step-by-step (CifsSyncTestServer) and come back to the first one later.

Configuration CifsSyncTestServer {

    Import-DscResource -ModuleName PSDesiredStateConfiguration, xComputerManagement, xNetworking, xPendingReboot, xSmbShare

    Node $AllNodes.Where{$_.Role -contains "FileServer"}.NodeName {

The first part of the DSC configuration is fairly straightforward. It starts with the keyword ‘Configuration’ followed by a name, then the import of the DSC resources that define it.

For those that don’t know, resources in these configuration languages represent some configurable entity that has a desired state defined by the values you give its properties. The agent tests each of these entities on the target machine for compliance with the configuration, if it complies with the desired state then it is left alone and if it doesn’t the entity is automatically modified such that it complies. They’re good for ensuring machines conform to a strict specification and if they drift from the desired state for some reason they can correct themselves without any human involvement.

The full list of supported DSC resources can be viewed in PowerShell Gallery here, they’re all tagged with DSCResourceKit. You can find the source code for any individual resource on GitHub at the URL: https://github.com/PowerShell/{ResourceName} where {ResourceName} is the name of the resource, e.g. https://github.com/PowerShell/xCertificate.

xDnsServerAddress SetDnsServers {
    InterfaceAlias = "Ethernet"
    AddressFamily = "IPv4"
    Address = $ConfigurationData.Domain.DnsServers
}

xComputer DomainJoin {
    Name = "localhost"
    DomainName = $ConfigurationData.Domain.Name
    Credential = New-Object -TypeName System.Management.Automation.PSCredential(
                     $ConfigurationData.Domain.AdministratorUser,
                     (ConvertTo-SecureString -String $ConfigurationData.Domain.AdministratorPassword -AsPlainText -Force)
                 )
    DependsOn = "[xDnsServerAddress]SetDnsServers"
}

The first two resources I’ve used are xDnsServerAddress and xComputer. You might be wondering what the x prefix is all about, it means Microsoft exclude them from their usual support programs and that any issues you have with them should be filed on GitHub. The x stands for experimental, but I find them to be quite high quality despite the name. Each one of these worked on Windows Server 2016 without a fault.

xDnsServerAddress configures the DNS servers on the NIC with the given name (InterfaceAlias). I take the exact addresses used from a data structure called $ConfigurationData, which I will explain later. xComputer enforces the machine’s identity, I use it to make sure that it is joined to my local domain, variables for which are also taken from the $ConfigurationData structure. Notice the use of DependsOn in the xComputer resource, we use this to tell the DSC agent that the domain join depends on the DNS servers being set correctly (otherwise the machine wouldn’t be able to resolve the domain). We use it to describe the dependencies between our desired states.

WindowsFeature FileServices {
    Name = "File-Services"
    Credential = New-Object -TypeName System.Management.Automation.PSCredential(
                     $Node.LocalAdministratorUser,
                     (ConvertTo-SecureString -String $Node.LocalAdministratorPassword -AsPlainText -Force)
                 )
    Ensure = "Present"
}

File ShareDirectory {
    DestinationPath = $Node.SharePath
    Type = "Directory"
    Ensure = "Present"
}

xSmbShare Share {
    Name = "CifsShare"
    Path = $Node.SharePath
    Ensure = "Present"
    ReadAccess = $Node.ShareReadAccess
    FullAccess = $Node.ShareFullAccess
    DependsOn = "[WindowsFeature]FileServices", "[File]ShareDirectory"
}

Next, we use three resources to make sure our target machine actually becomes a file server and this time only one of them is experimental.

WindowsFeature is (unsurprisingly) concerned with the management of Windows Features. Here we want to ensure the File-Services feature is installed, which is what makes a Windows Server a CIFS/SMB file server in the first place. Ensure = “Present” ensures that it is installed rather than uninstalled. We also have to provide some local Administrator credentials in the event it does need to be installed, we take these from a $Node data structure, which like $ConfigurationData I will explain shortly.

The File resource creates files and directories. We use it to create a directory (Type = “Directory”) at the given path from the $Node structure. The xSmbShare resource ensures that the previously created directory is an SMB share, it controls which domain users have full or read-only access with the FullAccess and ReadAccess properties.

Usually, resources test and enforce the desired state in the order that they appear in the configuration (like in Chef), but the agent isn’t obliged to do this in DSC’s case. With the above three resources, our WindowsFeature and File states can be applied in either order and it will work, but the xSmbShare resource requires them both to be successfully handled before it can be configured. We ensure this goes afterward by declaring them both in the DependsOn property.

xPendingReboot Reboot {
    Name = "Reboot"
    DependsOn = "[xComputer]DomainJoin"
}

Finally, if xComputer performed a domain join then there will be a pending reboot that we need to perform before the target machine has that domain identity. The DSC agent can handle that pending reboot for us with the xPendingReboot resource. By default, the DSC agent doesn’t allow reboots as part of configuring a node unless we opt-in to them. So next, we need to configure it to allow these.

Configuring the LocalConfigurationManager

The DSC agent’s name is the LocalConfigurationManager and we configure it with its own Configuration block that uses the [DSCLocalConfigurationManager()] annotation. That’s what the first of our two configurations above (the one called LcmConfig) is. Let’s look at it again:

[DSCLocalConfigurationManager()]
Configuration LcmConfig
{
    Node "localhost"
    {
        Settings
        {
            RefreshMode = "Push"
            ConfigurationMode = "ApplyOnly"
            RebootNodeIfNeeded = $true
        }
    }
}

Note: This way of configuring the LCM is valid from PowerShell 5.0 onwards. In PowerShell 4.0 it’s configured using a resource rather than a separate configuration. More details here.

The LCM has three configuration modes:

  • ApplyOnly for simply applying a configuration when called upon and taking no further action.
  • ApplyAndMonitor for applying a configuration then pro-actively monitoring the target machine for if/when it drifts from the configuration.
  • ApplyAndAutoCorrect for applying a configuration then pro-actively correcting any drift from that configuration.

In my case, I only want the initial application so I set ConfigurationMode to ApplyOnly.

The LCM has two refresh modes, a mode called Push that only consumes a configuration when it is applied by some other system and a mode called Pull, where it pro-actively goes to a central configuration server to find a configuration to apply. As I’m in a dev testing scenario and using Vagrant to control the lifetime of the machine, I set this to Push with the RefreshMode property.

Finally, I do want the LCM to handle reboots for me as I’m using the xPendingReboot resource, so RebootNodeIfNeeded gets set to $true.

Applying environment data to the configuration

In PowerShell DSC it’s good practice to keep environment-specific data separate from the configurations themselves, this makes those configurations more reusable. For example, if my local domain name were hard-coded into the above configuration you would have to modify it to reuse it in your own domain. I could have done that if I wanted to, but if you remember I used a variable from the $ConfigurationData data structure instead. Let’s take a look at how that is defined:

# Configuration data. Mainly contains variables specific to my environment. This pattern keeps
# these out of the configuration itself to make it more reusable. I have seperated node-specific
# data from more global things in my environment, like what domain to use.
$configData = @{
    AllNodes = @(
        @{
            NodeName = "localhost"
            Role = "FileServer"
            PSDscAllowPlainTextPassword = $true
            LocalAdministratorUser = "vagrant"
            LocalAdministratorPassword = "vagrant"
            SharePath = "C:\SmbShare"
            ShareReadAccess = "ReadUser@anchorloop.local"
            ShareFullAccess = "WriteUser@anchorloop.local"
        }
    );

    Domain = @{
        Name = "anchorloop.local"
        DnsServers = "192.168.0.50", "194.168.4.100"
        AdministratorUser = "Administrator@anchorloop.local"
        AdministratorPassword = "vagrant"
    }
}

# Compile our DSC configuration into a MOF file. Data-drive it with our environment data.
CifsSyncTestServer -OutputPath ".\MOF" -ConfigurationData $configData

The $ConfigurationData reference you have seen thus far is a reference to the ConfigurationData argument used when the configuration is compiled into an intermediary format called MOF (Managed Object File). This is a simple PowerShell hashtable, in my case called $configData. It contains an array called AllNodes that lists the nodes involved in my environment, each with a Role that corresponds to one or more configurations. The elements of this array are a great place to put data specific to each individual node. In my case, I’ve put local administrator credentials here and what domain users I expect to be able to use the file share created on it.

I’ve put variables describing my domain in a separate hashtable called Domain. If I had multiple machines in this environment they would likely share the same domain, so it doesn’t make sense to represent this on a per-node basis.

The configuration data can be organized however you like, as long as your configurations know how to interpret it when they are compiled. There isn’t really a perscribed format, so this gives you lots of power for data-driving configurations.

Integrating DSC into Vagrant Provisioning

There are only a few more lines of code required to integrate this into a Vagrant provisioner. I put the CifsSyncTestServer and LcmConfig DSC configurations into a .ps1 file called CifsSyncTestServer.ps1. Then I add the $configData data structure to a seperate .ps1 file called RunDsc.ps1 as follows:

# Install required DSC resources from PowerShell Gallery
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module xComputerManagement, xNetworking, xPendingReboot, xSmbShare

# Source configuration
. C:\vagrant\CifsSyncTestServer.ps1

# Configuration data. Mainly contains variables specific to my environment. This pattern keeps
# these out of the configuration itself to make it more reusable. I have seperated node-specific
# data from more global things in my environment, like what domain to use.
$configData = @{
    AllNodes = @(
        @{
            NodeName = "localhost"
            Role = "FileServer"
            PSDscAllowPlainTextPassword = $true
            LocalAdministratorUser = "vagrant"
            LocalAdministratorPassword = "vagrant"
            SharePath = "C:\SmbShare"
            ShareReadAccess = "ReadUser@anchorloop.local"
            ShareFullAccess = "WriteUser@anchorloop.local"
        }
    );

    Domain = @{
        Name = "anchorloop.local"
        DnsServers = "192.168.0.50", "194.168.4.100"
        AdministratorUser = "Administrator@anchorloop.local"
        AdministratorPassword = "vagrant"
    }
}

# Compile and apply LocalConfigurationManager config.
LcmConfig -OutputPath ".\LcmConfig"
Set-DscLocalConfigurationManager -Path ".\LcmConfig"

# Compile our DSC configuration into a MOF file. Data-drive it with our environment data.
CifsSyncTestServer -OutputPath ".\MOF" -ConfigurationData $configData

# Apply the configuration to the target machine.
Start-DscConfiguration -Path ".\MOF" -Wait
  • When Vagrant provisions a clean Windows Server it won’t have any of the required DSC resources on it, so RunDsc.ps1 begins by installing them from PowerShell Gallery.
  • Then it dot-sources the file containing the DSC configurations from the shared Vagrant directory, such that they are in our local scope.
  • The LcmConfig configuration containing the LCM’s settings are compiled into a MOF file and applied to the local LCM.
  • Then our main configuration is compiled into a MOF file with the contents of the $configData hashtable.
  • Finally, the configuration is applied to the Vagrant provisioned machine with Start-DscConfiguration.

Because DSC is part of PowerShell, we can use Vagrant’s shell provisioner to execute RunDsc.ps1. My Vagrantfile is as follows:

Vagrant.configure("2") do |config|
  config.vm.box = "WindowsServer2016"
  config.vm.communicator = "winrm"
  config.vm.synced_folder ".", "/vagrant"
  config.vm.provision "shell", path: "RunDsc.ps1"
end

Nice and concise! I’m now just a vagrant up away from a clean Windows file server integrated into my domain, ready for testing with.

About the Author Kirk MacPhee

An experienced software developer and technical lead, specializing in automation technologies and their application.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s