Tutorial: Create a class-based DSC Resource for machine configuration
Get started authoring a class-based DSC Resource to manage a configuration file with Azure machine configuration. Completing this tutorial gives you a machine-configuration compatible class-based DSC Resource in a module you can use for further learning and customization.
In this tutorial, you learn how to:
- Scaffold a DSC Resource module
- Add a class-based DSC Resource
- Define DSC Resource properties
- Implement the DSC Resource methods
- Export a DSC Resource in a module manifest
- Manually test a DSC Resource
Note
The example output in this tutorial matches PowerShell 7.2 on a Windows computer. The tutorial is valid with Windows PowerShell and with PowerShell on a Linux or macOS computer. Only the output is specific to running the commands in PowerShell on a Windows computer.
Prerequisites
- PowerShell or Windows PowerShell 5.1
- VS Code with the PowerShell extension
1 - Scaffold a DSC Resource module
DSC Resources must be defined in a PowerShell module.
Create the module folder
Create a new folder called ExampleResources
. This folder is used as the root folder for the
module and all code in this tutorial.
New-Item -Path './ExampleResources' -ItemType Directory
Directory: C:\code\dsc
Mode LastWriteTime Length Name
---- ------------- ------ ----
d---- 9/8/2022 12:54 PM ExampleResources
Use VS Code to author the module
Open the ExampleResources
folder in VS Code. Open the integrated terminal in VS Code. Make sure
your terminal is running PowerShell or Windows PowerShell.
Important
For the rest of this tutorial, run the specified commands in the integrated terminal at the root of the module folder. This is the default working directory in VS Code.
Create the module files
Create the module manifest with the New-ModuleManifest
cmdlet. Use ./ExampleResources.psd1
as
the Path. Specify RootModule as ExampleResources.psm1
and DscResourcesToExport as
Tailspin
.
$ModuleSettings = @{
RootModule = 'ExampleResources.psm1'
DscResourcesToExport = 'Tailspin'
}
New-ModuleManifest -Path ./ExampleResources.psd1 @ModuleSettings
Get-Module -ListAvailable -Name ./ExampleResources.psd1 | Format-List
Name : ExampleResources
Path : C:\code\dsc\ExampleResources\ExampleResources.psd1
Description :
ModuleType : Script
Version : 0.0.1
PreRelease :
NestedModules : {}
ExportedFunctions :
ExportedCmdlets :
ExportedVariables :
ExportedAliases :
Create the root module file as ExampleResources.psm1
.
New-Item -Path ./ExampleResources.psm1
Directory: C:\code\dsc\ExampleResources
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a--- 9/8/2022 1:57 PM 0 ExampleResources.psm1
Create a script file called Helpers.ps1
.
New-Item -Path ./Helpers.ps1
Directory: C:\code\dsc\ExampleResources
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a--- 9/8/2022 1:58 PM 0 Helpers.ps1
Open Helpers.ps1
in VS Code. Add the following line.
$env:PSModulePath += "$([System.IO.Path]::PathSeparator)$pwd"
Open ExampleResources.psm1
in VS Code. The module is now scaffolded and ready for you to author a
DSC Resource.
2 - Add a class-based DSC Resource
To define a class-based DSC Resource, we write a PowerShell class in a module file and add the DscResource attribute to it.
Define the class
In ExampleResources.psm1
, add the following code:
[DscResource()]
class Tailspin {
}
This code adds Tailspin
as a class-based DSC Resource to the ExampleResources module.
Hover on [DscResource()]
and read the warnings.
Hovering on the DSCResource attribute displays four warnings. 1. The DSC Resource 'Tailspin' is missing a `Set()` method that returns `[void]` and accepts no parameters. 2. The DSC Resource 'Tailspin' is missing a `Get()` method that returns `[Tailspin]` and accepts no parameters. 3. The DSC Resource 'Tailspin' is missing a `Test()` method that returns `[bool]` and accepts no parameters. 4. The DSC Resource 'Tailspin' must have at least one key property (using the syntax `[DscProperty(Key)]`.)
These warnings list the requirements for the class to be valid DSC Resource.
Minimally implement required methods
Add a minimal implementation of the Get()
, Test()
, and Set()
methods to the class.
class Tailspin {
[Tailspin] Get() {
$CurrentState = [Tailspin]::new()
return $CurrentState
}
[bool] Test() {
return $true
}
[void] Set() {}
}
With the methods added, the DscResource attribute only warns about the class not having a Key property.
3 - Define DSC Resource properties
You should define the properties of the DSC Resource before the methods. The properties define the manageable settings for the DSC Resource. They're used in the methods.
Understand the TSToy application
Before you can define the properties of your DSC Resource, you need to understand what settings you want to manage.
For this tutorial, we're defining a DSC Resource for managing the settings of the fictional TSToy application through its configuration file. TSToy is an application that has configuration at the user and machine levels. The DSC Resource should be able to configure either file.
The DSC Resource should enable users to define:
- The scope of the configuration they're managing, either
Machine
orUser
- Whether the configuration file should exist
- Whether TSToy should update automatically
- How frequently TSToy should check for updates, between 1 and 90 days
Add the ConfigurationScope property
To manage the Machine
or User
configuration file, you need to define a property of the DSC
Resource. To define the $ConfigurationScope
property in the resource, add the following code in
the class before the methods:
[DscProperty(Key)] [TailspinScope]
$ConfigurationScope
This code defines $ConfigurationScope
as a Key property of the DSC Resource. A Key
property is used to uniquely identify an instance of the DSC Resource. Adding this property meets
one of the requirements the DscResource attribute warned about when you scaffolded the class.
It also specifies that $ConfigurationScope
's type is TailspinScope. To define the
TailspinScope type, add the following TailspinScope enum after the class definition in
ExampleResources.psm1
:
enum TailspinScope {
Machine
User
}
This enumeration makes Machine
and User
the only valid values for the $ConfigurationScope
property of the DSC Resource.
Add the Ensure property
It's best practice to define an $Ensure
property to control whether an instance of a DSC Resource
exists. An $Ensure
property usually has two valid values, Absent
and Present
.
- If
$Ensure
is specified asPresent
, the DSC Resource creates the item if it doesn't exist. - If
$Ensure
isAbsent
, the DSC Resource deletes the item if it exists.
For the Tailspin DSC Resource, the item to create or delete is the configuration file for the
specified $ConfigurationScope
.
Define TailspinEnsure as an enum after TailspinScope. It should have the values Absent
and
Present
.
enum TailspinEnsure {
Absent
Present
}
Next, add the $Ensure
property in the class after the $ConfigurationScope
property. It should
have an empty DscProperty attribute and its type should be TailspinEnsure. It should default
to Present
.
[DscProperty()] [TailspinEnsure]
$Ensure = [TailspinEnsure]::Present
Add the UpdateAutomatically property
To manage automatic updates, define the $UpdateAutomatically
property in the class after the
$Ensure
property. Its DscProperty attribute should indicate that it's mandatory and its type
should be boolean.
[DscProperty(Mandatory)] [bool]
$UpdateAutomatically
Add the UpdateFrequency property
To manage how often TSToy should check for updates, add the $UpdateFrequency
property in the
class after the $UpdateAutomatically
property. It should have an empty DscProperty attribute
and its type should be int. Use the ValidateRange attribute to limit the valid values for
$UpdateFrequency
to between 1 and 90.
[DscProperty()] [int] [ValidateRange(1, 90)]
$UpdateFrequency
Add the Reasons property
Because this DSC Resource is intended for use with Azure machine configuration, it must have Reasons property that meets the following requirements:
- It must be declared with the NotConfigurable property on the DscProperty attribute.
- It must be an array of objects that have a String property named Code, a String property named Phrase, and no other properties.
Machine configuration uses the Reasons property to standardize how compliance information is
presented. Each object returned by the Get()
method for the Reasons property identifies how
and why an instance of the DSC Resource isn't compliant.
Machine configuration uses the Reasons property to standardize how compliance information is
presented. Each object returned by the Get()
method for the Reasons property identifies one
of the DSC Resource's properties, its desired state, and its actual state.
To define the Reasons property, you need to define a class for it. Define the ExampleResourcesReason class after the TailspinEnsure enum. It should have the Code and Phrase properties as strings. Both properties should have the DscProperty attribute.
To make the reasons display more readably during manual testing, define the ToString()
method on
the ExampleResourcesReason class as shown in the code snippet.
class ExampleResourcesReason {
[DscProperty()]
[string] $Code
[DscProperty()]
[string] $Phrase
[string] ToString() {
return "`n$($this.Code):`n`t$($this.Phrase)`n"
}
}
Next, add the $Reasons
property in the DSC Resource's class after the $UpdateFrequency
property. It should have the DscProperty attribute specified with the NotConfigurable
option
and its type should be ExampleResourcesReason[].
[DscProperty(NotConfigurable)] [ExampleResourcesReason[]]
$Reasons
Add hidden cache properties
Next, add two hidden properties for caching the current state of the resource: $CachedCurrentState
and $CachedData
. Set the type of $CachedCurrentState
to Tailspin, the same as the class and
the return type for the Get()
method. Set they type of $CachedData
to PSCustomObject. Prefix
both properties with the hidden
keyword. Don't specify the DscProperty attribute for either.
hidden [Tailspin] $CachedCurrentState
hidden [PSCustomObject] $CachedData
These hidden properties are used in the Get()
and Set()
methods that you define later.
Review the module file
At this point, ExampleResources.psm1
should define:
- The Tailspin class with the properties
$ConfigurationScope
,$Ensure
,$UpdateAutomatically
, and$UpdateFrequency
- The TailspinScope enum with the values
Machine
andUser
- The TailspinEnsure enum with the values
Present
andAbsent
- The minimal implementations of the
Get()
,Test()
, andSet()
methods.
[DscResource()]
class Tailspin {
[DscProperty(Key)] [TailspinScope]
$ConfigurationScope
[DscProperty()] [TailspinEnsure]
$Ensure = [TailspinEnsure]::Present
[DscProperty(Mandatory)] [bool]
$UpdateAutomatically
[DscProperty()] [int] [ValidateRange(1,90)]
$UpdateFrequency
[DscProperty(NotConfigurable)] [ExampleResourcesReason[]]
$Reasons
hidden [Tailspin] $CachedCurrentState
hidden [PSCustomObject] $CachedData
[Tailspin] Get() {
$CurrentState = [Tailspin]::new()
return $CurrentState
}
[bool] Test() {
$InDesiredState = $true
return $InDesiredState
}
[void] Set() {}
}
enum TailspinScope {
Machine
User
}
enum TailspinEnsure {
Absent
Present
}
class ExampleResourcesReason {
[DscProperty()]
[string] $Code
[DscProperty()]
[string] $Phrase
[string] ToString() {
return "`n$($this.Code):`n`t$($this.Phrase)`n"
}
}
Now that the DSC Resource meets the requirements, you can use Get-DscResource
to see it. In VS
Code, open a new PowerShell terminal.
. ./Helpers.ps1
Get-DscResource -Name Tailspin -Module ExampleResources | Format-List
Get-DscResource -Name Tailspin -Module ExampleResources -Syntax
ImplementationDetail : ClassBased
ResourceType : Tailspin
Name : Tailspin
FriendlyName :
Module : ExampleResources
ModuleName : ExampleResources
Version : 0.0.1
Path : C:\code\dsc\ExampleResources\ExampleResources.psd1
ParentPath : C:\code\dsc\ExampleResources
ImplementedAs : PowerShell
CompanyName : Unknown
Properties : {ConfigurationScope, UpdateAutomatically, DependsOn, Ensureā¦}
Tailspin [String] #ResourceName
{
ConfigurationScope = [string]{ Machine | User }
UpdateAutomatically = [bool]
[DependsOn = [string[]]]
[Ensure = [string]{ Absent | Present }]
[PsDscRunAsCredential = [PSCredential]]
[UpdateFrequency = [Int32]]
}
4 - Implement the DSC Resource methods
The methods of the DSC Resource define how to retrieve the current state of a DSC Resource, validate it against the desired state, and enforce the desired state.
The Get method
The Get()
method retrieves the current state of the DSC Resource. It's used to inspect a DSC
Resource manually and is called by the Test()
method.
The Get()
method has no parameters and returns an instance of the class as its output. For the
Tailspin
DSC Resource, the minimal implementation looks like this:
[Tailspin] Get() {
$CurrentState = [Tailspin]::new()
return $CurrentState
}
The only thing this implementation does is create an instance of the Tailspin class and return
it. You can call the method with Invoke-DscResource
to see this behavior.
Invoke-DscResource -Name Tailspin -Module ExampleResources -Method Get -Property @{
ConfigurationScope = 'User'
UpdateAutomatically = $true
}
ConfigurationScope Ensure UpdateAutomatically UpdateFrequency
------------------ ------ ------------------- ---------------
Machine Present False 0
The returned object's properties are all set to their default value. The value of
$ConfigurationScope
should always be the value the user supplied. To make the Get()
method
useful, it must return the actual state of the DSC Resource.
[Tailspin] Get() {
$CurrentState = [Tailspin]::new()
$CurrentState.ConfigurationScope = $this.ConfigurationScope
$this.CachedCurrentState = $CurrentState
return $CurrentState
}
The $this
variable references the working instance of the DSC Resource. Now, if you use
Invoke-DscResource
again, $ConfigurationScope
has the correct value.
Invoke-DscResource -Name Tailspin -Module ExampleResources -Method Get -Property @{
ConfigurationScope = 'User'
UpdateAutomatically = $true
}
ConfigurationScope Ensure UpdateAutomatically UpdateFrequency
------------------ ------ ------------------- ---------------
User Present False 0
Next, the DSC Resource needs to determine whether the configuration file exists. If it does,
$Ensure
should be Present
. If it doesn't, $Ensure
should be Absent
.
The location of TSToy's configuration files depends on the operating system and configuration scope:
- For Windows machines:
- The
Machine
configuration file is%PROGRAMDATA%\TailSpinToys\tstoy\tstoy.config.json
- The
User
configuration file is%APPDATA%\TailSpinToys\tstoy\tstoy.config.json
- The
- For Linux machines:
- The
Machine
configuration file is/etc/xdg/TailSpinToys/tstoy/tstoy.config.json
- The
User
configuration file is~/.config/TailSpinToys/tstoy/tstoy.config.json
- The
- For macOS machines:
- The
Machine
configuration file is/Library/Preferences/TailSpinToys/tstoy/tstoy.config.json
- The
User
configuration file is~/Library/Preferences/TailSpinToys/tstoy/tstoy.config.json
- The
To handle these paths, you need to create a helper method, GetConfigurationFile()
.
[string] GetConfigurationFile() {
$FilePaths = @{
Linux = @{
Machine = '/etc/xdg/TailSpinToys/tstoy/tstoy.config.json'
User = '~/.config/TailSpinToys/tstoy/tstoy.config.json'
}
MacOS = @{
Machine = '/Library/Preferences/TailSpinToys/tstoy/tstoy.config.json'
User = '~/Library/Preferences/TailSpinToys/tstoy/tstoy.config.json'
}
Windows = @{
Machine = "$env:ProgramData\TailSpinToys\tstoy\tstoy.config.json"
User = "$env:APPDATA\TailSpinToys\tstoy\tstoy.config.json"
}
}
$Scope = $this.ConfigurationScope.ToString()
if ($Global:PSVersionTable.PSVersion.Major -lt 6 -or $Global:IsWindows) {
return $FilePaths.Windows.$Scope
} elseif ($Global:IsLinux) {
return $FilePaths.Linux.$Scope
} else {
return $FilePaths.MacOS.$Scope
}
}
To test this new method, execute the using
statement to load the ExampleResources module's
classes and enums into your current session.
using module ./ExampleResources.psd1
$Example = [Tailspin]::new()
$Example
$Example.GetConfigurationFile()
$Example.ConfigurationScope = 'User'
$Example.GetConfigurationFile()
Ensure ConfigurationScope UpdateAutomatically UpdateFrequency
------- ------------------ ------------------- ---------------
Present Machine False 0
C:\ProgramData\TailSpinToys\tstoy\tstoy.config.json
C:\Users\mikey\AppData\Roaming\TailSpinToys\tstoy\tstoy.config.json
Open Helpers.ps1
in VS Code. Copy and paste the paths for the configuration files into the script,
assigning them to $TSToyMachinePath
and $TSToyUserPath
. The file should look like this:
$env:PSModulePath += "<separator>$pwd"
$TSToyMachinePath = '<machine configuration file path>'
$TSToyUserPath = '<user configuration file path>'
Exit the terminal in VS Code and open a new terminal. Dot-source Helpers.ps1
.
. ./Helpers.ps1
Now you can write the rest of the Get()
method.
[Tailspin] Get() {
$CurrentState = [Tailspin]::new()
$CurrentState.ConfigurationScope = $this.ConfigurationScope
$FilePath = $this.GetConfigurationFile()
if (!(Test-Path -Path $FilePath)) {
$CurrentState.Ensure = [TailspinEnsure]::Absent
$this.CachedCurrentState = $CurrentState
return $CurrentState
}
$Data = Get-Content -Raw -Path $FilePath |
ConvertFrom-Json -ErrorAction Stop
$this.CachedData = $Data
if ($null -ne $Data.Updates.Automatic) {
$CurrentState.UpdateAutomatically = $Data.Updates.Automatic
}
if ($null -ne $Data.Updates.CheckFrequency) {
$CurrentState.UpdateFrequency = $Data.Updates.CheckFrequency
}
$this.CachedCurrentState = $CurrentState
return $CurrentState
}
After setting the $ConfigurationScope
and determining the configuration file's path, the method
checks to see if the file exists. If it doesn't exist, setting $Ensure
to Absent
and returning
the result is all that's needed.
If the file does exist, the method needs to convert the contents from JSON to create the current state of the configuration. Next, the method checks to see if the keys have any value before assigning them to the current state's properties. If they're not specified, the DSC Resource must consider them unset and in their default state.
At this point, the DSC Resource caches the data. Caching the data allows you to inspect the data
during development and is useful when implementing the Set()
method.
You can verify this behavior locally.
$GetParameters = @{
Name = 'Tailspin'
Module = 'ExampleResources'
Method = 'Get'
Property = @{
ConfigurationScope = 'User'
}
}
Invoke-DscResource @GetParameters
New-Item -Path $TSToyUserPath -Force
Invoke-DscResource @GetParameters
ConfigurationScope Ensure UpdateAutomatically UpdateFrequency
------------------ ------ ------------------- ---------------
User Absent False 0
Directory: C:\Users\mikey\AppData\Roaming\TailSpinToys\tstoy
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a--- 9/15/2022 3:43 PM 0 tstoy.config.json
ConfigurationScope Ensure UpdateAutomatically UpdateFrequency
------------------ ------ ------------------- ---------------
User Present False 0
Open the User
scope configuration file in VS Code.
code $TSToyUserPath
Copy this JSON configuration into the file and save it.
{
"unmanaged_key": true,
"updates": {
"automatic": true,
"checkFrequency": 30
}
}
Call Invoke-DscResource
again and see the values reflected in the results.
Invoke-DscResource @GetParameters
ConfigurationScope Ensure UpdateAutomatically UpdateFrequency
------------------ ------ ------------------- ---------------
User Present True 30
The Get()
method now returns accurate information about the current state of the DSC Resource.
Handling Reasons
For Azure machine configuration compatible DSC Resources, the Get()
method also needs to populate
the Reasons property. For this purpose, create the GetReasons()
method. It should return an
array of ExampleResourcesReason objects and take a single Tailspin object as input.
[ExampleResourcesReason[]] GetReasons([Tailspin]$CurrentState) {
[ExampleResourcesReason[]]$DefinedReasons = @()
return $DefinedReasons
}
Next, the method needs to check the validity of each configurable setting. For every setting, the method needs to return an ExampleResourcesReason identifying and describing the state.
The first setting to verify is the $Ensure
property. If $Ensure
is out of state, all other
properties will be incorrect since the configuration file exists when it shouldn't or doesn't exist
when it should.
The method needs to define a reason with the correct code and a sensible phrase. The code is always
in the format <ResourceName>.<ResourceName>.<PropertyName>
. The Phrase is always a sentence
describing the check, followed by sentences describing the expected state and actual state.
[ExampleResourcesReason[]] GetReasons([Tailspin]$CurrentState) {
[ExampleResourcesReason[]]$DefinedReasons = @()
$FilePath = $this.GetConfigurationFile()
if ($this.Ensure -eq [TailspinEnsure]::Present) {
$Expected = "Expected configuration file to exist at '$FilePath'."
} else {
$Expected = "Expected configuration file not to exist at '$FilePath'."
}
if ($CurrentState.Ensure -eq [TailspinEnsure]::Present) {
$Actual = "The configuration file exists at '$FilePath'."
} else {
$Actual = "The configuration file was not found at '$FilePath'."
}
$DefinedReasons += [ExampleResourcesReason]@{
Code = "Tailspin.Tailspin.Ensure"
Phrase = @(
"Checked existence of the TSToy configuration file in the $($this.ConfigurationScope) scope."
$Expected
$Actual
) -join "`n`t"
}
if ($CurrentState.Ensure -ne $this.Ensure) {
return $DefinedReasons
}
return $DefinedReasons
}
If $Ensure
isn't out of state, the method should check if the desired state is Absent
. If it is,
there's no other properties that can be out of state because the configuration file doesn't exist
and shouldn't exist.
[ExampleResourcesReason[]] GetReasons([Tailspin]$CurrentState) {
[ExampleResourcesReason[]]$DefinedReasons = @()
$FilePath = $this.GetConfigurationFile()
if ($this.Ensure -eq [TailspinEnsure]::Present) {
$Expected = "Expected configuration file to exist at '$FilePath'."
} else {
$Expected = "Expected configuration file not to exist at '$FilePath'."
}
if ($CurrentState.Ensure -eq [TailspinEnsure]::Present) {
$Actual = "The configuration file exists at '$FilePath'."
} else {
$Actual = "The configuration file was not found at '$FilePath'."
}
$DefinedReasons += [ExampleResourcesReason]@{
Code = "Tailspin.Tailspin.Ensure"
Phrase = @(
"Checked existence of the TSToy configuration file in the $($this.ConfigurationScope) scope."
$Expected
$Actual
) -join "`n`t"
}
if ($CurrentState.Ensure -ne $this.Ensure) {
return $DefinedReasons
}
if ($CurrentState.Ensure -eq [TailspinEnsure]::Absent) {
return $DefinedReasons
}
return $DefinedReasons
}
If $Ensure
is Present
and the file exists, the method needs to check the remaining configurable
properties.
Checking the $UpdateAutomatically
property is straightforward, since it's a mandatory and boolean
value.
[ExampleResourcesReason[]] GetReasons([Tailspin]$CurrentState) {
[ExampleResourcesReason[]]$DefinedReasons = @()
$FilePath = $this.GetConfigurationFile()
if ($this.Ensure -eq [TailspinEnsure]::Present) {
$Expected = "Expected configuration file to exist at '$FilePath'."
} else {
$Expected = "Expected configuration file not to exist at '$FilePath'."
}
if ($CurrentState.Ensure -eq [TailspinEnsure]::Present) {
$Actual = "The configuration file exists at '$FilePath'."
} else {
$Actual = "The configuration file was not found at '$FilePath'."
}
$DefinedReasons += [ExampleResourcesReason]@{
Code = "Tailspin.Tailspin.Ensure"
Phrase = @(
"Checked existence of the TSToy configuration file in the $($this.ConfigurationScope) scope."
$Expected
$Actual
) -join "`n`t"
}
if ($CurrentState.Ensure -ne $this.Ensure) {
return $DefinedReasons
}
if ($CurrentState.Ensure -eq [TailspinEnsure]::Absent) {
return $DefinedReasons
}
$DefinedReasons += [ExampleResourcesReason]@{
Code = "Tailspin.Tailspin.UpdateAutomatically"
Phrase = (@(
"Checked value of the 'updates.automatic' key in the TSToy configuration file."
"Expected boolean value of '$($this.UpdateAutomatically)'"
"Actual boolean value of '$($CurrentState.UpdateAutomatically)'"
) -join "`n`t")
}
return $DefinedReasons
}
The last property to check is $UpdateFrequency
. This check can be short-circuited if the value of
the property is 0
. If the property is specified it will always be between 1 and 90, meaning a
value of 0
indicates the property isn't being managed.
[ExampleResourcesReason[]] GetReasons([Tailspin]$CurrentState) {
[ExampleResourcesReason[]]$DefinedReasons = @()
$FilePath = $this.GetConfigurationFile()
if ($this.Ensure -eq [TailspinEnsure]::Present) {
$Expected = "Expected configuration file to exist at '$FilePath'."
} else {
$Expected = "Expected configuration file not to exist at '$FilePath'."
}
if ($CurrentState.Ensure -eq [TailspinEnsure]::Present) {
$Actual = "The configuration file exists at '$FilePath'."
} else {
$Actual = "The configuration file was not found at '$FilePath'."
}
$DefinedReasons += [ExampleResourcesReason]@{
Code = "Tailspin.Tailspin.Ensure"
Phrase = @(
"Checked existence of the TSToy configuration file in the $($this.ConfigurationScope) scope."
$Expected
$Actual
) -join "`n`t"
}
if ($CurrentState.Ensure -ne $this.Ensure) {
return $DefinedReasons
}
if ($CurrentState.Ensure -eq [TailspinEnsure]::Absent) {
return $DefinedReasons
}
$DefinedReasons += [ExampleResourcesReason]@{
Code = "Tailspin.Tailspin.UpdateAutomatically"
Phrase = (@(
"Checked value of the 'updates.automatic' key in the TSToy configuration file."
"Expected boolean value of '$($this.UpdateAutomatically)'"
"Actual boolean value of '$($CurrentState.UpdateAutomatically)'"
) -join "`n`t")
}
return $DefinedReasons
# Short-circuit the check; UpdateFrequency isn't defined by caller
if ($this.UpdateFrequency -eq 0) {
return $DefinedReasons
}
}
Finally, the method needs to compare the desired and current states of the $UpdateFrequency
property and define a reason if they're out of state.
[ExampleResourcesReason[]] GetReasons([Tailspin]$CurrentState) {
[ExampleResourcesReason[]]$DefinedReasons = @()
$FilePath = $this.GetConfigurationFile()
if ($this.Ensure -eq [TailspinEnsure]::Present) {
$Expected = "Expected configuration file to exist at '$FilePath'."
} else {
$Expected = "Expected configuration file not to exist at '$FilePath'."
}
if ($CurrentState.Ensure -eq [TailspinEnsure]::Present) {
$Actual = "The configuration file exists at '$FilePath'."
} else {
$Actual = "The configuration file was not found at '$FilePath'."
}
$DefinedReasons += [ExampleResourcesReason]@{
Code = "Tailspin.Tailspin.Ensure"
Phrase = @(
"Checked existence of the TSToy configuration file in the $($this.ConfigurationScope) scope."
$Expected
$Actual
) -join "`n`t"
}
if ($CurrentState.Ensure -ne $this.Ensure) {
return $DefinedReasons
}
if ($CurrentState.Ensure -eq [TailspinEnsure]::Absent) {
return $DefinedReasons
}
$DefinedReasons += [ExampleResourcesReason]@{
Code = "Tailspin.Tailspin.UpdateAutomatically"
Phrase = (@(
"Checked value of the 'updates.automatic' key in the TSToy configuration file."
"Expected boolean value of '$($this.UpdateAutomatically)'"
"Actual boolean value of '$($CurrentState.UpdateAutomatically)'"
) -join "`n`t")
}
# Short-circuit the check; UpdateFrequency isn't defined by caller
if ($this.UpdateFrequency -eq 0) {
return $DefinedReasons
}
$DefinedReasons += [ExampleResourcesReason]@{
Code = "Tailspin.Tailspin.UpdateFrequency"
Phrase = (@(
"Checked value of the 'updates.checkFrequency' key in the TSToy configuration file."
"Expected integer value of '$($this.UpdateFrequency)'."
"Actual integer value of '$($CurrentState.UpdateFrequency)'."
) -join "`n`t")
}
return $DefinedReasons
}
With the GetReasons()
method implemented, the Get()
method needs to be updated to call it before
returning the current state.
[Tailspin] Get() {
$CurrentState = [Tailspin]::new()
$CurrentState.ConfigurationScope = $this.ConfigurationScope
$FilePath = $this.GetConfigurationFile()
if (!(Test-Path -Path $FilePath)) {
$CurrentState.Ensure = [TailspinEnsure]::Absent
$CurrentState.Reasons = $this.GetReasons($CurrentState)
$this.CachedCurrentState = $CurrentState
return $CurrentState
}
$Data = Get-Content -Raw -Path $FilePath |
ConvertFrom-Json -ErrorAction Stop
$this.CachedData = $Data
if ($null -ne $Data.Updates.Automatic) {
$CurrentState.UpdateAutomatically = $Data.Updates.Automatic
}
if ($null -ne $Data.Updates.CheckFrequency) {
$CurrentState.UpdateFrequency = $Data.Updates.CheckFrequency
}
$CurrentState.Reasons = $this.GetReasons($CurrentState)
$this.CachedCurrentState = $CurrentState
return $CurrentState
}
With the implementation updated, you can verify the behavior and see that the reasons are reported:
$SharedParameters = @{
Name = 'Tailspin'
Module = 'ExampleResources'
Property = @{
ConfigurationScope = 'User'
Ensure = 'Present'
UpdateAutomatically = $false
}
}
Invoke-DscResource -Method Get @SharedParameters
$SharedParameters.Property.UpdateAutomatically = $true
Invoke-DscResource -Method Get @SharedParameters
$SharedParameters.Property.UpdateFrequency = 1
Invoke-DscResource -Method Get @SharedParameters
ConfigurationScope : User
Ensure : Present
UpdateAutomatically : True
UpdateFrequency : 1
Reasons : {
Tailspin.Tailspin.Ensure:
Checked existence of the TSToy configuration file in
the User scope.
Expected configuration file to exist at 'C:\Users\mlomba
rdi\AppData\Roaming\TailSpinToys\tstoy\tstoy.config.json'
.
The configuration file exists at 'C:\Users\mlombardi\App
Data\Roaming\TailSpinToys\tstoy\tstoy.config.json'.
,
Tailspin.Tailspin.UpdateAutomatically:
Checked value of the 'updates.automatic' key in the
TSToy configuration file.
Expected boolean value of 'False'
Actual boolean value of 'True'
}
ConfigurationScope : User
Ensure : Present
UpdateAutomatically : True
UpdateFrequency : 1
Reasons : {
Tailspin.Tailspin.Ensure:
Checked existence of the TSToy configuration file in
the User scope.
Expected configuration file to exist at 'C:\Users\mlomba
rdi\AppData\Roaming\TailSpinToys\tstoy\tstoy.config.json'
.
The configuration file exists at 'C:\Users\mlombardi\App
Data\Roaming\TailSpinToys\tstoy\tstoy.config.json'.
,
Tailspin.Tailspin.UpdateAutomatically:
Checked value of the 'updates.automatic' key in the
TSToy configuration file.
Expected boolean value of 'True'
Actual boolean value of 'True'
}
ConfigurationScope : User
Ensure : Present
UpdateAutomatically : True
UpdateFrequency : 1
Reasons : {
Tailspin.Tailspin.Ensure:
Checked existence of the TSToy configuration file in
the User scope.
Expected configuration file to exist at 'C:\Users\mlomba
rdi\AppData\Roaming\TailSpinToys\tstoy\tstoy.config.json'
.
The configuration file exists at 'C:\Users\mlombardi\App
Data\Roaming\TailSpinToys\tstoy\tstoy.config.json'.
,
Tailspin.Tailspin.UpdateAutomatically:
Checked value of the 'updates.automatic' key in the
TSToy configuration file.
Expected boolean value of 'True'
Actual boolean value of 'True'
,
Tailspin.Tailspin.UpdateFrequency:
Checked value of the 'updates.checkFrequency' key in
the TSToy configuration file.
Expected integer value of '1'.
Actual integer value of '1'.
}
The Test method
With the Get()
method implemented, you can verify whether the current state is compliant with the
desired state.
The Test()
methods minimal implementation always returns $true
.
[bool] Test() {
return $true
}
You can verify that by running Invoke-DscResource
.
$SharedParameters = @{
Name = 'Tailspin'
Module = 'ExampleResources'
Property = @{
ConfigurationScope = 'User'
UpdateAutomatically = $false
}
}
Invoke-DscResource -Method Get @SharedParameters
Invoke-DscResource -Method Test @SharedParameters
ConfigurationScope Ensure UpdateAutomatically UpdateFrequency
------------------ ------ ------------------- ---------------
User Present True 30
InDesiredState
--------------
True
You need to make the Test()
method accurately reflect whether the DSC Resource is in the desired
state. The Test()
method should always call the Get()
method to have the current state to
compare against the desired state. Then check is whether the $Ensure
property is correct. If it
isn't, return $false
immediately. No further checks are required if the $Ensure
property is out
of the desired state.
[bool] Test() {
$CurrentState = $this.Get()
if ($CurrentState.Ensure -ne $this.Ensure) {
return $false
}
return $true
}
Now you can verify the updated behavior.
$TestParameters = @{
Name = 'Tailspin'
Module = 'ExampleResources'
Property = @{
ConfigurationScope = 'User'
UpdateAutomatically = $false
Ensure = 'Absent'
}
}
Invoke-DscResource -Method Test @TestParameters
$TestParameters.Property.Ensure = 'Present'
Invoke-DscResource -Method Test @TestParameters
InDesiredState
--------------
False
InDesiredState
--------------
True
Next, check to see if the value of $Ensure
is Absent
. If the configuration file doesn't exist
and shouldn't exist, there's no reason to check the remaining properties.
[bool] Test() {
$CurrentState = $this.Get()
if ($CurrentState.Ensure -ne $this.Ensure) {
return $false
}
if ($CurrentState.Ensure -eq [TailspinEnsure]::Absent) {
return $true
}
return $true
}
Next, the method needs to compare the current state of the properties that manage TSToy's update
behavior. First, check to see if the $UpdateAutomatically
property is in the correct state. If it
isn't, return $false
.
[bool] Test() {
$CurrentState = $this.Get()
if ($CurrentState.Ensure -ne $this.Ensure) {
return $false
}
if ($CurrentState.Ensure -eq [TailspinEnsure]::Absent) {
return $true
}
if ($CurrentState.UpdateAutomatically -ne $this.UpdateAutomatically) {
return $false
}
return $true
}
To compare $UpdateFrequency
, we need to determine if the user specified the value. Because
$UpdateFrequency
is initialized to 0
and the property's ValidateRange attribute specifies
that it must be between 1
and 90
, we know that a value of 0
indicates that the property wasn't
specified.
With that information, the Test()
method should:
- Return
$true
if the user didn't specify$UpdateFrequency
- Return
$false
if the user did specify$UpdateFrequency
and the value of the system doesn't equal the user-specified value - Return
$true
if neither of the prior conditions were met
[bool] Test() {
$CurrentState = $this.Get()
if ($CurrentState.Ensure -ne $this.Ensure) {
return $false
}
if ($CurrentState.Ensure -eq [TailspinEnsure]::Absent) {
return $true
}
if ($CurrentState.UpdateAutomatically -ne $this.UpdateAutomatically) {
return $false
}
if ($this.UpdateFrequency -eq 0) {
return $true
}
if ($CurrentState.UpdateFrequency -ne $this.UpdateFrequency) {
return $false
}
return $true
}
Now the Test()
method uses the following order of operations:
- Retrieve the current state of TSToy's configuration.
- Return
$false
if the configuration exists when it should not or does not exist when it should. - Return
$true
if the configuration does not exist and should not exist. - Return
$false
if the configuration's automatic update setting doesn't match the desired one. - Return
$true
if the user didn't specify a value for the update frequency setting. - Return
$false
if the user's specified value for the update frequency setting doesn't match the configuration's setting. - Return
$true
if none of the prior conditions were met.
You can verify the Test()
method locally:
$SharedParameters = @{
Name = 'Tailspin'
Module = 'ExampleResources'
Property = @{
ConfigurationScope = 'User'
Ensure = 'Present'
UpdateAutomatically = $false
}
}
Invoke-DscResource -Method Get @SharedParameters
Invoke-DscResource -Method Test @SharedParameters
$SharedParameters.Property.UpdateAutomatically = $true
Invoke-DscResource -Method Test @SharedParameters
$SharedParameters.Property.UpdateFrequency = 1
Invoke-DscResource -Method Test @SharedParameters
ConfigurationScope Ensure UpdateAutomatically UpdateFrequency
------------------ ------ ------------------- ---------------
User Present True 30
InDesiredState
--------------
False
InDesiredState
--------------
True
InDesiredState
--------------
False
With this code, the Test()
method is able to accurately determine whether the configuration file
is in the desired state.
The Set method
Now that the Get()
and Test()
methods reliably work, you can define the Set()
method to
actually enforce the desired state.
In the minimal implementation, the Set()
method does nothing.
[void] Set() {}
First, Set()
needs to determine whether the DSC Resource needs to be created, updated, or removed.
[void] Set() {
if ($this.Test()) {
return
}
$CurrentState = $this.CachedCurrentState
$IsAbsent = $CurrentState.Ensure -eq [TailspinEnsure]::Absent
$ShouldBeAbsent = $this.Ensure -eq [TailspinEnsure]::Absent
if ($IsAbsent) {
# Create
} elseif ($ShouldBeAbsent) {
# Remove
} else {
# Update
}
}
Set()
first calls the Test()
method to determine if anything actually needs to be done. Some
tools like, Azure machine configuration, ensure that the Set()
method is only called after the
Test()
method. However, there's no such guarantee when you use the Invoke-DscResource
cmdlet.
Since the Test()
method calls Get()
, which caches the current state, the DSC Resource can access
the cached current state without having to call the Get()
method again.
Next, the DSC Resource needs to distinguish between create, remove, and update behaviors for the configuration file. If the configuration file doesn't exist, we know it should be created. If the configuration file does exist and shouldn't, we know it needs to be removed. If the configuration file does exist and should exist, we know it needs to be updated.
Create three new methods to handle these operations and call them in the Set()
method as needed.
The return type for all three should be void.
[void] Set() {
if ($this.Test()) {
return
}
$CurrentState = $this.CachedCurrentState
$IsAbsent = $CurrentState.Ensure -eq [TailspinEnsure]::Absent
$ShouldBeAbsent = $this.Ensure -eq [TailspinEnsure]::Absent
if ($IsAbsent) {
$this.Create()
} elseif ($ShouldBeAbsent) {
$this.Remove()
} else {
$this.Update()
}
}
[void] Create() {}
[void] Remove() {}
[void] Update() {}
Also, create a new method called ToConfigJson()
. Its return type should be string. This method
converts the DSC Resource into the JSON that the configuration file expects. You can start with the
following minimal implementation:
[string] ToConfigJson() {
$config = @{}
return ($config | ConvertTo-Json)
}
The ToConfigJson method
The minimal implementation returns an empty JSON object as a string. To make it useful, it needs to return the actual JSON representation of the settings in TSToy's configuration file.
First, prepopulate the $config
hashtable with the mandatory automatic updates setting by
adding the updates
key with its value as a hashtable. The hashtable should have the
automatic
key. Assign the value of the class's $UpdateAutomatically
property to the automatic
key.
[string] ToConfigJson() {
$config = @{
updates = @{
automatic = $this.UpdateAutomatically
}
}
return ($config | ConvertTo-Json)
}
This code translates the DSC Resource representation of TSToy's settings to the structure that TSToy's configuration file expects.
Next, the method needs to check whether the class has cached the data from an existing configuration file. The cached data allows the DSC Resource to manage the defined settings without overwriting or removing unmanaged settings.
[string] ToConfigJson() {
$config = @{
updates = @{
automatic = $this.UpdateAutomatically
}
}
if ($this.CachedData) {
# Copy unmanaged settings without changing the cached values
$this.CachedData |
Get-Member -MemberType NoteProperty |
Where-Object -Property Name -NE -Value 'updates' |
ForEach-Object -Process {
$setting = $_.Name
$config.$setting = $this.CachedData.$setting
}
# Add the checkFrequency to the hashtable if it is set in the cache
if ($frequency = $this.CachedData.updates.checkFrequency) {
$config.updates.checkFrequency = $frequency
}
}
# If the user specified an UpdateFrequency, use that value
if ($this.UpdateFrequency -ne 0) {
$config.updates.checkFrequency = $this.UpdateFrequency
}
return ($config | ConvertTo-Json)
}
If the class has cached the settings from an existing configuration, it:
Inspects the cached data's properties, looking for any properties the DSC Resource doesn't manage. If it finds any, the method inserts those unmanaged properties into the
$config
hashtable.Because the DSC Resource only manages the update settings, every setting except for
updates
is inserted.Checks to see if the
checkFrequency
setting inupdates
is set. If it's set, the method inserts this value into the$config
hashtable.This operation allows the DSC Resource to ignore the
$UpdateFrequency
property if the user doesn't specify it.Finally, the method needs to check if the user specified the
$UpdateFrequency
property and insert it into the$config
hashtable if they did.
With this code, the ToConfigJson()
method:
- Returns an accurate JSON representation of the desired state that the TSToy application expects in its configuration file
- Respects any of TSToy's settings that the DSC Resource doesn't explicitly manage
- Respects the existing value for TSToy's update frequency if the user didn't specify one, including leaving it undefined in the configuration file
To test this new method, close your VS Code terminal and open a new one. Execute the using
statement to load the ExampleResources module's classes and enums into your current session and
dot-source the helpers.ps1
script.
using module ./ExampleResources.psd1
. ./Helpers.ps1
$Example = [Tailspin]::new()
Get-Content -Path $TSToyUserPath
$Example.ConfigurationScope = 'User'
$Example.ToConfigJson()
Before the Get()
method is called, the only value in the output of the ToJsonConfig method is
the converted value for the $UpdateAutomatically
property.
{
"unmanaged_key": true,
"updates": {
"automatic": false,
"checkFrequency": 30
}
}
{
"updates": {
"automatic": false
}
}
$Example.Get()
$Example.ToConfigJson()
After you call Get()
, the output includes an unmanaged top-level key, unmanaged_key
. It also
includes the existing setting in the configuration file for $UpdateFrequency
since it wasn't
explicitly set on the DSC Resource.
ConfigurationScope : User
Ensure : Present
UpdateAutomatically : False
UpdateFrequency : 30
Reasons : {}
{
"unmanaged_key": true,
"updates": {
"automatic": false,
"checkFrequency": 30
}
}
$Example.UpdateFrequency = 7
$Example.ToConfigJson()
After $UpdateFrequency
is set, the output reflects the specified value.
{
"unmanaged_key": true,
"updates": {
"automatic": false,
"checkFrequency": 7
}
}
The Create method
To implement the Create()
method, we need to convert the user-specified properties for the DSC
Resource into the JSON that TSToy expects in its configuration file and write it to that file.
[void] Create() {
$ErrorActionPreference = 'Stop'
$Json = $this.ToConfigJson()
$FilePath = $this.GetConfigurationFile()
$FolderPath = Split-Path -Path $FilePath
if (!(Test-Path -Path $FolderPath)) {
New-Item -Path $FolderPath -ItemType Directory -Force
}
Set-Content -Path $FilePath -Value $Json -Encoding utf8 -Force
}
The method uses the ToConfigJson()
method to get the JSON for the configuration file. It checks
whether the configuration file's folder exists and creates it if necessary. Finally, it creates the
configuration file and writes the JSON to it.
The Remove method
The Remove()
method has the simplest behavior. If the configuration file exists, delete it.
[void] Remove() {
Remove-Item -Path $this.GetConfigurationFile() -Force -ErrorAction Stop
}
The Update method
The Update()
method's implementation is similar to the Create method. It needs to convert the
user-specified properties for the DSC Resource into the JSON that TSToy expects in its configuration
file and replace the settings in that file.
[void] Update() {
$ErrorActionPreference = 'Stop'
$Json = $this.ToConfigJson()
$FilePath = $this.GetConfigurationFile()
Set-Content -Path $FilePath -Value $Json -Encoding utf8 -Force
}
5 - Manually test a DSC Resource
With the DSC Resource fully implemented, you can now test its behavior.
Before testing, close your VS Code terminal and open a new one. Dot-source the Helpers.ps1
script.
For each test scenario, create the $DesiredState
hashtable containing the shared parameters and
call the methods in the following order:
Get()
, to retrieve the initial state of the DSC ResourceTest()
, to see whether the DSC Resource considers it to be in the desired stateSet()
, to enforce the desired stateTest()
, to see whether the DSC Resource considers it to be set correctlyGet()
, to confirm the final state of the DSC Resource
Scenario: TSToy shouldn't update automatically in the user scope
In this scenario, the existing configuration in the user scope needs to be configured not to update automatically. All other settings should be left untouched.
. ./Helpers.ps1
$DesiredState = @{
Name = 'Tailspin'
Module = 'ExampleResources'
Property = @{
ConfigurationScope = 'User'
UpdateAutomatically = $false
Ensure = 'Present'
}
}
Get-Content -Path $TSToyUserPath
Invoke-DscResource @DesiredState -Method Get
Invoke-DscResource @DesiredState -Method Test
Invoke-DscResource @DesiredState -Method Set
Invoke-DscResource @DesiredState -Method Test
Invoke-DscResource @DesiredState -Method Get
Get-Content -Path $TSToyUserPath
{
"unmanaged_key": true,
"updates": {
"automatic": true,
"checkFrequency": 30
}
}
ConfigurationScope : User
Ensure : Present
UpdateAutomatically : True
UpdateFrequency : 30
Reasons : {
Tailspin.Tailspin.Ensure:
Checked existence of the TSToy configuration file in
the User scope.
Expected configuration file to exist at 'C:\Users\mlomba
rdi\AppData\Roaming\TailSpinToys\tstoy\tstoy.config.json'
.
The configuration file exists at 'C:\Users\mlombardi\App
Data\Roaming\TailSpinToys\tstoy\tstoy.config.json'.
,
Tailspin.Tailspin.UpdateAutomatically:
Checked value of the 'updates.automatic' key in the
TSToy configuration file.
Expected boolean value of 'False'
Actual boolean value of 'True'
}
InDesiredState : False
RebootRequired : False
InDesiredState : True
ConfigurationScope : User
Ensure : Present
UpdateAutomatically : False
UpdateFrequency : 30
Reasons : {
Tailspin.Tailspin.Ensure:
Checked existence of the TSToy configuration file in
the User scope.
Expected configuration file to exist at 'C:\Users\mlomba
rdi\AppData\Roaming\TailSpinToys\tstoy\tstoy.config.json'
.
The configuration file exists at 'C:\Users\mlombardi\App
Data\Roaming\TailSpinToys\tstoy\tstoy.config.json'.
,
Tailspin.Tailspin.UpdateAutomatically:
Checked value of the 'updates.automatic' key in the
TSToy configuration file.
Expected boolean value of 'False'
Actual boolean value of 'False'
}
{
"unmanaged_key": true,
"updates": {
"automatic": false,
"checkFrequency": 30
}
}
Scenario: Tailspin should update automatically on any schedule in the user scope
In this scenario, the existing configuration in the user scope needs to be configured to update automatically. All other settings should be left untouched.
. ./Helpers.ps1
$DesiredState = @{
Name = 'Tailspin'
Module = 'ExampleResources'
Property = @{
ConfigurationScope = 'User'
UpdateAutomatically = $true
Ensure = 'Present'
}
}
Get-Content -Path $TSToyUserPath
Invoke-DscResource @DesiredState -Method Get
Invoke-DscResource @DesiredState -Method Test
Invoke-DscResource @DesiredState -Method Set
Invoke-DscResource @DesiredState -Method Test
Invoke-DscResource @DesiredState -Method Get
Get-Content -Path $TSToyUserPath
{
"unmanaged_key": true,
"updates": {
"automatic": false,
"checkFrequency": 30
}
}
ConfigurationScope : User
Ensure : Present
UpdateAutomatically : False
UpdateFrequency : 30
Reasons : {
Tailspin.Tailspin.Ensure:
Checked existence of the TSToy configuration file in
the User scope.
Expected configuration file to exist at 'C:\Users\mlomba
rdi\AppData\Roaming\TailSpinToys\tstoy\tstoy.config.json'
.
The configuration file exists at 'C:\Users\mlombardi\App
Data\Roaming\TailSpinToys\tstoy\tstoy.config.json'.
,
Tailspin.Tailspin.UpdateAutomatically:
Checked value of the 'updates.automatic' key in the
TSToy configuration file.
Expected boolean value of 'True'
Actual boolean value of 'False'
}
InDesiredState : False
RebootRequired : False
InDesiredState : True
ConfigurationScope : User
Ensure : Present
UpdateAutomatically : True
UpdateFrequency : 30
Reasons : {
Tailspin.Tailspin.Ensure:
Checked existence of the TSToy configuration file in
the User scope.
Expected configuration file to exist at 'C:\Users\mlomba
rdi\AppData\Roaming\TailSpinToys\tstoy\tstoy.config.json'
.
The configuration file exists at 'C:\Users\mlombardi\App
Data\Roaming\TailSpinToys\tstoy\tstoy.config.json'.
,
Tailspin.Tailspin.UpdateAutomatically:
Checked value of the 'updates.automatic' key in the
TSToy configuration file.
Expected boolean value of 'True'
Actual boolean value of 'True'
}
{
"unmanaged_key": true,
"updates": {
"automatic": true,
"checkFrequency": 30
}
}
Scenario: TSToy should update automatically every day in the user scope
In this scenario, the existing configuration in the user scope needs to be configured to update automatically and daily. All other settings should be left untouched.
. ./Helpers.ps1
$DesiredState = @{
Name = 'Tailspin'
Module = 'ExampleResources'
Property = @{
ConfigurationScope = 'User'
UpdateAutomatically = $true
UpdateFrequency = 1
Ensure = 'Present'
}
}
Get-Content -Path $TSToyUserPath
Invoke-DscResource @DesiredState -Method Get
Invoke-DscResource @DesiredState -Method Test
Invoke-DscResource @DesiredState -Method Set
Invoke-DscResource @DesiredState -Method Test
Invoke-DscResource @DesiredState -Method Get
Get-Content -Path $TSToyUserPath
{
"unmanaged_key": true,
"updates": {
"automatic": true,
"checkFrequency": 30
}
}
ConfigurationScope : User
Ensure : Present
UpdateAutomatically : True
UpdateFrequency : 30
Reasons : {
Tailspin.Tailspin.Ensure:
Checked existence of the TSToy configuration file in
the User scope.
Expected configuration file to exist at 'C:\Users\mlomba
rdi\AppData\Roaming\TailSpinToys\tstoy\tstoy.config.json'
.
The configuration file exists at 'C:\Users\mlombardi\App
Data\Roaming\TailSpinToys\tstoy\tstoy.config.json'.
,
Tailspin.Tailspin.UpdateAutomatically:
Checked value of the 'updates.automatic' key in the
TSToy configuration file.
Expected boolean value of 'True'
Actual boolean value of 'True'
,
Tailspin.Tailspin.UpdateFrequency:
Checked value of the 'updates.checkFrequency' key in
the TSToy configuration file.
Expected integer value of '1'.
Actual integer value of '30'.
}
InDesiredState : False
RebootRequired : False
InDesiredState : True
ConfigurationScope : User
Ensure : Present
UpdateAutomatically : True
UpdateFrequency : 1
Reasons : {
Tailspin.Tailspin.Ensure:
Checked existence of the TSToy configuration file in
the User scope.
Expected configuration file to exist at 'C:\Users\mlomba
rdi\AppData\Roaming\TailSpinToys\tstoy\tstoy.config.json'
.
The configuration file exists at 'C:\Users\mlombardi\App
Data\Roaming\TailSpinToys\tstoy\tstoy.config.json'.
,
Tailspin.Tailspin.UpdateAutomatically:
Checked value of the 'updates.automatic' key in the
TSToy configuration file.
Expected boolean value of 'True'
Actual boolean value of 'True'
,
Tailspin.Tailspin.UpdateFrequency:
Checked value of the 'updates.checkFrequency' key in
the TSToy configuration file.
Expected integer value of '1'.
Actual integer value of '1'.
}
{
"unmanaged_key": true,
"updates": {
"checkFrequency": 1,
"automatic": true
}
}
Scenario: TSToy shouldn't have a user scope configuration
In this scenario, the configuration file for TSToy in the user scope shouldn't exist. If it does, the DSC Resource should delete the file.
. ./Helpers.ps1
$DesiredState = @{
Name = 'Tailspin'
Module = 'ExampleResources'
Property = @{
ConfigurationScope = 'User'
UpdateAutomatically = $false
Ensure = 'Absent'
}
}
Get-Content -Path $TSToyUserPath
Invoke-DscResource @DesiredState -Method Get
Invoke-DscResource @DesiredState -Method Test
Invoke-DscResource @DesiredState -Method Set
Invoke-DscResource @DesiredState -Method Test
Invoke-DscResource @DesiredState -Method Get
Test-Path -Path $TSToyUserPath
{
"unmanaged_key": true,
"updates": {
"checkFrequency": 1,
"automatic": true
}
}
ConfigurationScope : User
Ensure : Present
UpdateAutomatically : True
UpdateFrequency : 1
Reasons : {
Tailspin.Tailspin.Ensure:
Checked existence of the TSToy configuration file in
the User scope.
Expected configuration file not to exist at 'C:\Users\ml
ombardi\AppData\Roaming\TailSpinToys\tstoy\tstoy.config.j
son'.
The configuration file exists at 'C:\Users\mlombardi\App
Data\Roaming\TailSpinToys\tstoy\tstoy.config.json'.
}
InDesiredState : False
RebootRequired : False
InDesiredState : True
ConfigurationScope : User
Ensure : Absent
UpdateAutomatically : False
UpdateFrequency : 0
Reasons : {
Tailspin.Tailspin.Ensure:
Checked existence of the TSToy configuration file in
the User scope.
Expected configuration file not to exist at 'C:\Users\ml
ombardi\AppData\Roaming\TailSpinToys\tstoy\tstoy.config.j
son'.
The configuration file was not found at 'C:\Users\mlomba
rdi\AppData\Roaming\TailSpinToys\tstoy\tstoy.config.json'
.
}
False
Scenario: TSToy should update automatically every week in the machine scope
In this scenario, there's no defined configuration in the machine scope. The machine scope needs to be configured to update automatically and daily. The DSC Resource should create the file and any parent folders as required.
. ./Helpers.ps1
$DesiredState = @{
Name = 'Tailspin'
Module = 'ExampleResources'
Property = @{
ConfigurationScope = 'Machine'
UpdateAutomatically = $true
Ensure = 'Present'
}
}
Test-Path -Path $TSToyMachinePath, (Split-Path -Path $TSToyMachinePath)
Invoke-DscResource @DesiredState -Method Get
Invoke-DscResource @DesiredState -Method Test
Invoke-DscResource @DesiredState -Method Set
Invoke-DscResource @DesiredState -Method Test
Invoke-DscResource @DesiredState -Method Get
Get-Content -Path $TSToyMachinePath
False
False
ConfigurationScope : Machine
Ensure : Absent
UpdateAutomatically : False
UpdateFrequency : 0
Reasons : {
Tailspin.Tailspin.Ensure:
Checked existence of the TSToy configuration file in
the Machine scope.
Expected configuration file to exist at
'C:\ProgramData\TailSpinToys\tstoy\tstoy.config.json'.
The configuration file was not found at
'C:\ProgramData\TailSpinToys\tstoy\tstoy.config.json'.
}
InDesiredState : False
RebootRequired : False
InDesiredState : True
ConfigurationScope : Machine
Ensure : Present
UpdateAutomatically : True
UpdateFrequency : 0
Reasons : {
Tailspin.Tailspin.Ensure:
Checked existence of the TSToy configuration file in
the Machine scope.
Expected configuration file to exist at
'C:\ProgramData\TailSpinToys\tstoy\tstoy.config.json'.
The configuration file exists at
'C:\ProgramData\TailSpinToys\tstoy\tstoy.config.json'.
,
Tailspin.Tailspin.UpdateAutomatically:
Checked value of the 'updates.automatic' key in the
TSToy configuration file.
Expected boolean value of 'True'
Actual boolean value of 'True'
}
{
"updates": {
"automatic": true
}
}
Review
In this tutorial, you:
- Scaffolded a PowerShell module and implemented the
Tailspin
class-based DSC Resource - Defined the DSC Resource's properties to manage the TSToy application's update behavior in the machine and user scopes with validation for those properties
- Implemented enums for the
$Ensure
and$ConfigurationScope
properties - Implemented the
ExampleResourcesReason
class for reporting how a resource is out-of-state in machine configuration - Implemented the
GetConfigurationFile()
helper method to reliably discover the location of TSToy's application config in the machine and user scopes across platforms - Implemented the
Get()
method to retrieve the current state of the DSC Resource, caching it for use in theTest()
andSet()
methods - Implemented the
GetReasons()
helper method for validating whether the DSC Resource is in the desired state and, if it isn't, enumerating how it's out of state - Implemented the
Test()
method to validate the current state of TSToy's update behavior in a specific scope against the desired state - Implemented the ToConfigJson method to convert the desired state of the DSC Resource into the JSON object the TSToy application requires for its configuration file, respecting unmanaged settings
- Implemented the
Set()
method and the Create, Remove, and Update helper methods to idempotently enforce the desired state for TSToy's update behavior in a specific scope, ensuring that the DSC Resource doesn't have undesirable side effects - Manually tested common usage scenarios for the DSC Resource
At the end of your implementation, your module definition looks like this:
[DscResource()]
class Tailspin {
[DscProperty(Key)] [TailspinScope]
$ConfigurationScope
[DscProperty()] [TailspinEnsure]
$Ensure = [TailspinEnsure]::Present
[DscProperty(Mandatory)] [bool]
$UpdateAutomatically
[DscProperty()] [int] [ValidateRange(1, 90)]
$UpdateFrequency
[DscProperty(NotConfigurable)] [ExampleResourcesReason[]]
$Reasons
hidden [Tailspin] $CachedCurrentState
hidden [PSCustomObject] $CachedData
[Tailspin] Get() {
$CurrentState = [Tailspin]::new()
$CurrentState.ConfigurationScope = $this.ConfigurationScope
$FilePath = $this.GetConfigurationFile()
if (!(Test-Path -Path $FilePath)) {
$CurrentState.Ensure = [TailspinEnsure]::Absent
$CurrentState.Reasons = $this.GetReasons($CurrentState)
$this.CachedCurrentState = $CurrentState
return $CurrentState
}
$Data = Get-Content -Raw -Path $FilePath |
ConvertFrom-Json -ErrorAction Stop
$this.CachedData = $Data
if ($null -ne $Data.Updates.Automatic) {
$CurrentState.UpdateAutomatically = $Data.Updates.Automatic
}
if ($null -ne $Data.Updates.CheckFrequency) {
$CurrentState.UpdateFrequency = $Data.Updates.CheckFrequency
}
$CurrentState.Reasons = $this.GetReasons($CurrentState)
$this.CachedCurrentState = $CurrentState
return $CurrentState
}
[bool] Test() {
$CurrentState = $this.Get()
if ($CurrentState.Ensure -ne $this.Ensure) {
return $false
}
if ($CurrentState.UpdateAutomatically -ne $this.UpdateAutomatically) {
return $false
}
if ($this.UpdateFrequency -eq 0) {
return $true
}
if ($CurrentState.UpdateFrequency -ne $this.UpdateFrequency) {
return $false
}
return $true
}
[void] Set() {
if ($this.Test()) {
return
}
$CurrentState = $this.CachedCurrentState
$IsAbsent = $CurrentState.Ensure -eq [TailspinEnsure]::Absent
$ShouldBeAbsent = $this.Ensure -eq [TailspinEnsure]::Absent
if ($IsAbsent) {
$this.Create()
}
elseif ($ShouldBeAbsent) {
$this.Remove()
}
else {
$this.Update()
}
}
[string] GetConfigurationFile() {
$FilePaths = @{
Linux = @{
Machine = '/etc/xdg/TailSpinToys/tstoy/tstoy.config.json'
User = '~/.config/TailSpinToys/tstoy/tstoy.config.json'
}
MacOS = @{
Machine = '/Library/Preferences/TailSpinToys/tstoy/tstoy.config.json'
User = '~/Library/Preferences/TailSpinToys/tstoy/tstoy.config.json'
}
Windows = @{
Machine = "$env:ProgramData\TailSpinToys\tstoy\tstoy.config.json"
User = "$env:APPDATA\TailSpinToys\tstoy\tstoy.config.json"
}
}
$Scope = $this.ConfigurationScope.ToString()
if ($Global:PSVersionTable.PSVersion.Major -lt 6 -or $Global:IsWindows) {
return $FilePaths.Windows.$Scope
}
elseif ($Global:IsLinux) {
return $FilePaths.Linux.$Scope
}
else {
return $FilePaths.MacOS.$Scope
}
}
[ExampleResourcesReason[]] GetReasons([Tailspin]$CurrentState) {
[ExampleResourcesReason[]]$DefinedReasons = @()
$FilePath = $this.GetConfigurationFile()
if ($this.Ensure -eq [TailspinEnsure]::Present) {
$Expected = "Expected configuration file to exist at '$FilePath'."
} else {
$Expected = "Expected configuration file not to exist at '$FilePath'."
}
if ($CurrentState.Ensure -eq [TailspinEnsure]::Present) {
$Actual = "The configuration file exists at '$FilePath'."
} else {
$Actual = "The configuration file was not found at '$FilePath'."
}
$DefinedReasons += [ExampleResourcesReason]@{
Code = "Tailspin.Tailspin.Ensure"
Phrase = @(
"Checked existence of the TSToy configuration file in the $($this.ConfigurationScope) scope."
$Expected
$Actual
) -join "`n`t"
}
if ($CurrentState.Ensure -ne $this.Ensure) {
return $DefinedReasons
}
if ($CurrentState.Ensure -eq [TailspinEnsure]::Absent) {
return $DefinedReasons
}
$DefinedReasons += [ExampleResourcesReason]@{
Code = "Tailspin.Tailspin.UpdateAutomatically"
Phrase = (@(
"Checked value of the 'updates.automatic' key in the TSToy configuration file."
"Expected boolean value of '$($this.UpdateAutomatically)'"
"Actual boolean value of '$($CurrentState.UpdateAutomatically)'"
) -join "`n`t")
}
# Short-circuit the check; UpdateFrequency isn't defined by caller
if ($this.UpdateFrequency -eq 0) {
return $DefinedReasons
}
$DefinedReasons += [ExampleResourcesReason]@{
Code = "Tailspin.Tailspin.UpdateFrequency"
Phrase = (@(
"Checked value of the 'updates.checkFrequency' key in the TSToy configuration file."
"Expected integer value of '$($this.UpdateFrequency)'."
"Actual integer value of '$($CurrentState.UpdateFrequency)'."
) -join "`n`t")
}
return $DefinedReasons
}
[void] Create() {
$ErrorActionPreference = 'Stop'
$Json = $this.ToConfigJson()
$FilePath = $this.GetConfigurationFile()
$FolderPath = Split-Path -Path $FilePath
if (!(Test-Path -Path $FolderPath)) {
New-Item -Path $FolderPath -ItemType Directory -Force
}
Set-Content -Path $FilePath -Value $Json -Encoding utf8 -Force
}
[void] Remove() {
Remove-Item -Path $this.GetConfigurationFile() -Force -ErrorAction Stop
}
[void] Update() {
$ErrorActionPreference = 'Stop'
$Json = $this.ToConfigJson()
$FilePath = $this.GetConfigurationFile()
Set-Content -Path $FilePath -Value $Json -Encoding utf8 -Force
}
[string] ToConfigJson() {
$config = @{
updates = @{
automatic = $this.UpdateAutomatically
}
}
if ($this.CachedData) {
$this.CachedData |
Get-Member -MemberType NoteProperty |
Where-Object -Property Name -NE -Value 'updates' |
ForEach-Object -Process {
$setting = $_.Name
$config.$setting = $this.CachedData.$setting
}
if ($frequency = $this.CachedData.updates.CheckFrequency) {
$config.updates.checkFrequency = $frequency
}
}
if ($this.UpdateFrequency -ne 0) {
$config.updates.checkFrequency = $this.UpdateFrequency
}
return ($config | ConvertTo-Json)
}
}
enum TailspinScope {
Machine
User
}
enum TailspinEnsure {
Absent
Present
}
class ExampleResourcesReason {
[DscProperty()]
[string] $Code
[DscProperty()]
[string] $Phrase
[string] ToString() {
return "`n$($this.Code):`n`t$($this.Phrase)`n"
}
}
Clean up
If you're not going to continue to use this module, delete the ExampleResources
folder and the
files in it.
Next steps
- Read about class-based DSC Resources, learn about how they work, and consider why the DSC Resource in this tutorial is implemented this way.
- Read about Azure machine configuration to understand how you can use it to audit and configure your systems.
- Consider how this DSC Resource can be improved. Are there any edge cases or features it doesn't handle? Update the implementation to handle them.