Microsoft365DSC icon indicating copy to clipboard operation
Microsoft365DSC copied to clipboard

Proposal - Change to class based resources

Open FabienTschanz opened this issue 6 months ago • 19 comments

Description

Desired State Configuration is on the brink of changing. DSCv3 was released and includes support for PowerShell DSC as well as Windows PowerShell DSC resources through adapters. Script based as well as class-based resources are supported, but you can also write it in other programming languages (C#, Go etc.).

To leverage the best features DSCv3 as well as fixing issues with WMI conversions (Yes, Get-DscConfiguration fails for nested WMI objects because it can't properly deserialize them from hashtables), we should consider moving to class-based resources with strongly typed resources.

This also has the benefit of reducing the number of times we have to define the properties. From currently three times (once in each function Get/Set/Test) to just once in the class definition, potentially saving us thousands of lines of code and reducing the overall size of the module. Another step to a cleaner, more functionable and reliable module.

A proposal implementation would be the following:

  1. Create a BaseResource class for shared methods and properties. A method SetPsBoundParameters is there because PowerShell classes don't have the $PSBoundParameters feature.
  2. Implement the Get/Set/Test methods. These can pretty much be copied as-is, while the properties of the resource must be replaced with $this.<propertyname> and for $MyInvocation also with $this.MyInvocation. The information of MyInvocation is not available but can be approximated with the BaseResource and its constructor.
  3. Leave the Export-TargetResource as-is. It is still fully functionable, no changes at all necessary.
  4. Updating the Microsoft365DSC.psd1 to include a RootModule = 'Microsoft365DSC.psm1' property.
  5. During build time, add all of the class resources (that can be stored under DSCClassResource) to Microsoft365DSC.psm1. This means that instead of using NestedModules with types that won't properly be loaded, we create a big file that contains all of the types and makes them available. This also means that the types are available in the other modules as well since it's module-scoped.

Some PowerShell code to illustrate what I mean. Final version of the Microsoft365DSC.psm1 with the class definitions:

$maximumFunctionCount = 32767

foreach ($module in (Get-ChildItem -Path $PSScriptRoot\Modules -Filter *.psm1 -Recurse)) {
    Import-Module -Name $module.FullName -Force
}

class BaseResource
{
    [DscProperty(NotConfigurable)]
    hidden [hashtable] $PSBoundParameters = @{}
    hidden static [bool] $IsInitialized = $false
    hidden [hashtable] $MyInvocation = @{}

    [void] SetPsBoundParameters()
    {
        $allProperties = $this.GetType().GetProperties() | Where-Object { $_.Name -notin @('PSBoundParameters', 'IsInitialized', 'MyInvocation') }
        foreach ($property in $allProperties)
        {
            if ($null -ne $this.$($property.Name))
            {
                $this.PSBoundParameters[$property.Name] = $this.$($property.Name)
            }
        }
    }
}

[DSCResource()]
class IntuneDeviceCleanupRule : BaseResource
{
    [DscProperty(Key)]
    [ValidateSet('Yes')]
    [System.String] $IsSingleInstance

    [DscProperty(Mandatory)]
    [Nullable[System.Boolean]] $Enabled

     ...

    [DscProperty()]
    [System.String[]] $AccessTokens

    IntuneDeviceCleanupRule()
    {
        if (-not [BaseResource]::IsInitialized)
        {
            [BaseResource]::IsInitialized = $true
        }

        $this.MyInvocation = Get-M365DSCMyInvocation -ClassName $this.GetType().Name -CommandPath $PSCommandPath
    }

    [IntuneDeviceCleanupRule] Get()
    {
        return $this.GetAsHashtable()
    }

    [System.Collections.Hashtable] GetAsHashtable()
    {
        Write-Verbose -Message 'Retrieving configuration of the Intune Device Cleanup Rule' -Verbose
        $this.SetPsBoundParameters()

        try
        {

            ...

            return $return
        }
        catch
        {
            New-M365DSCLogEntry -Message 'Error retrieving data:' `
                -Exception $_ `
                -Source $($this.MyInvocation.MyCommand.Source) `
                -TenantId $this.TenantId `
                -Credential $this.Credential

            return $nullResult
        }
    }

    [void] Set()
    {

        if ($this.Enabled -and $this.DeviceInactivityBeforeRetirementInDays -eq 0)
        {
            throw [System.ArgumentException]::new('DeviceInactivityBeforeRetirementInDays must be greater than 30 and less than 270 when Enabled is set to true.')
        }

        ...

        $url = (Get-MSCloudLoginConnectionProfile -Workload MicrosoftGraph).ResourceUrl + 'beta/deviceManagement/managedDeviceCleanupSettings'
        $body = @{
            DeviceInactivityBeforeRetirementInDays = "$(if ($this.Enabled) { $this.DeviceInactivityBeforeRetirementInDays } else { 0 })"
        }
        Invoke-MgGraphRequest -Method PATCH -Uri $url -Body ($body | ConvertTo-Json)
    }

    [bool] Test()
    {
        if ($this.Enabled -and $this.DeviceInactivityBeforeRetirementInDays -eq 0)
        {
            throw [System.ArgumentException]::new('DeviceInactivityBeforeRetirementInDays must be greater than 30 and less than 270 when Enabled is set to true.')
        }

        ...

        $TestResult = Test-M365DSCParameterState -CurrentValues $CurrentValues `
            -Source $($this.MyInvocation.MyCommand.Source) `
            -DesiredValues $this.PSBoundParameters `
            -ValuesToCheck $ValuesToCheck.Keys

        Write-Verbose -Message "Test-TargetResource returned $TestResult"

        return $TestResult
    }
}

------------------------------------------------- OPTIONAL FOR NON-LCM -------------------------------- If we were to ditch the LCM and PSDesiredStateConfiguration at all, we could even go further and have the PSBoundParameters functionality built in using ScriptProperty. This unfortunately doesn't work in the LCM because of some runspace issues (scriptblock can't be run in there, no idea why...), but with DSCv3, that works without issues. Or we use a custom implementation that fixes those very nasty bugs I encountered. Really ugly what they're doing with PSDesiredStateConfiguration...

Possible generic implementation in the BaseResource:

    BaseResource()
    {
        Write-Verbose "Constructor of BaseResource" -Verbose

        if (-not [BaseResource]::IsInitialized)
        {
            $classType = $this.GetType()
            $allProperties = $classType.GetProperties() | Where-Object { $_.Name -notin @('PSBoundParameters', 'IsInitialized') }
            $memberDefinitions = @()
            foreach ($property in $allProperties) {
                $memberDefinitions += @{
                    MemberType  = 'ScriptProperty'
                    MemberName  = $property.Name
                    Value       = [ScriptBlock]::Create("Write-Verbose 'Getting property $($property.Name)' -Verbose; `$this.PSBoundParameters['$($property.Name)']") # Getter
                    SecondValue = [ScriptBlock]::Create("Write-Verbose 'Setting property $($property.Name) with value `$$args[0]' -Verbose; `$this.PSBoundParameters['$($property.Name)'] = `$args[0]") # Setter
                }
            }

            foreach ($Definition in $memberDefinitions) {
                Write-Verbose -Message "Updating TypeData for $($classType.Name) with member $($Definition.MemberName))" -Verbose
                Update-TypeData -TypeName $classType.Name @Definition
            }

            [BaseResource]::IsInitialized = $true
        }
    }

Limitations

This proposal is not compatible with the LCM because of the runspace environment if we're using ScriptProperty as the way to go for building $PSBoundParameters (which would be the by far best way). If it will be decided to still support the LCM even after switching to classes, the first approach using the SetPsBoundParameters method is the way to go. But there we lose access to the bound parameters and cannot distinguish anymore, which properties were actually set with null and which are implicitly null.

Also, it must be considered that with the LCM support and method approach, we have to change certain property types to [Nullable[<type]] to possibly exclude value types that would otherwise be set with a default value (I'm looking at you int, bool and so on).

Suggestions, ideas, discussions are welcome.

FYI: @ricmestre, @Borgquite, @NikCharlebois, @ykuijs, @andikrueger, @salbeck-sit

FabienTschanz avatar Jun 20 '25 12:06 FabienTschanz

First thought: One thing I know was an issue in the past in the DSC Community was Pester, it did not support proper unit testing for classes (yet). Not sure if this has changed already.

ykuijs avatar Jun 20 '25 13:06 ykuijs

@ykuijs I'd have to try it, but I guess that should work. Most of the functions come from other modules (e.g. `M365DSCUtil' etc.) anyways, implying that it shouldn't change drastically. We'll probably have to update the setup script anyway. Do you want me to check if it will work?

Edit: Checking a couple of links like https://stackoverflow.com/questions/71787852/how-to-pester-test-a-method-in-a-class-module-psm1 show a very similar setup like we have. I'd say support is given, probably depends a bit on the amount of customized stuff in the module and class.

FabienTschanz avatar Jun 20 '25 14:06 FabienTschanz

Hey, on the Pester issue, based on discussion on the #dsc community Discord channel, I believe that Pester 5 supports unit testing for class-based resources.

I don't know about the $PSBoundParameters equivalent class-based support when using DSC 1.1 / LCM but have posted on the same channel asking someone to comment to see if there's a non-messy solution.

With regards to retaining support for DSC 1.1, I can only beg that we retain this, as for those of us who need desired state configuration in hybrid environments (e.g. users in Active Directory, distribution groups in Exchange Online) it's currently the only officially supported game out there, especially if you can't use Azure Machine Configuration for any reason (which, for example, still doesn't support managing reboots or secrets management / credentials).

There's a long discussion where I've been trying to raise these issues below, but despite its name, MSDSC v3 is far from feature complete compared to PSDSC 1.1 - in fact, it includes a mere fraction of the previously supported configuration and orchestration features, and the current roadmap is uncommitted as to whether they will ever be replaced. The MSDSC team are aware of this; at present, the goal is to focus on providing a 'standard' that can be used to configure resources, but expecting people to use third-party tools like Ansible, Chef, Puppet to compile configurations and deploy. Whereas PSDSC 1.1 supports all of this already. Of course many people are using Microsoft365DSC just for 'export and compare' so a move to MSDSC 3.0 wouldn't affect them. But others (like me) who are deploying and managing hybrid environments 'from scratch', losing support for PSDSC 1.1 and the LCM, would make it pretty much unusable.

This discussion is at https://github.com/PowerShell/DSC/discussions/529. I'm being told by one of the DSC team that people 'shouldn't feel any pressure to switch' from PSDSC 1.1 to MSDSC 3.0. But if Microsoft365DSC only supports MSDSC 3.0, I'd end up under a lot of pressure stuck between a DSC 1.1 dependency 'rock' and MSDSC 3.0 dependency 'hard place'. I guess the only option for me would be to fork Microsoft365DSC - so that it can continue to be used and developed it for these sorts of hybrid environments, until MSDSC 3.0 matures and/or some sort of alternative becomes available which supports the kind of scenarios which I need for my job.

To be honest some people are saying that because MSDSC 3.0 isn't actually intended to be a like replacement for the previous versions of PSDSC, and is more of a new product that has limited backwards compatibility with the PSDSC modules, perhaps Microsoft should have been given a different name to reflect this. It's certainly not a drop-in replacement for PSDSC; and right now, it's not intended to be.

Borgquite avatar Jun 23 '25 12:06 Borgquite

@Borgquite Thank you for your insights on the matter. I just stumbled upon the link https://mdgrs.hashnode.dev/scriptblock-and-sessionstate-in-powershell which might be relevant for my issue with the scriptblocks. I'll give them another try later.

I hear you when you say that you want to retain compatibility with the LCM. That's my personal goal as well. Having the issue with PSBoundParameters, it would be difficult to justify the reasons why we switched because it was working with functions.

Since it's still a proposal, my idea here is to gather information on the usage and possible impact, what scenarios we need to consider and maybe gain technical insights. It's not that we push this to the limit right now and have it implemented in two weeks. That's not going to be the case (although I'd like to do it 😆).

I don't know about the $PSBoundParameters equivalent class-based support when using DSC 1.1 / LCM but have posted on the same channel asking someone to comment to see if there's a non-messy solution.

On which channel was that? Would love to see where this is going. I hope that I'll be able to fix it with the above link. That would be great and take off any pressure.

Edit: Somebody just opened another issue about the LCM: https://github.com/PowerShell/DSC/issues/904

FabienTschanz avatar Jun 23 '25 14:06 FabienTschanz

@FabienTschanz Thanks - good luck w/ PSBoundParameters. I understand where you're coming from, and that maintaining backwards compatibility isn't always worth the effort - I hope it is here though :)

The DSC channel can be accessed via Slack or Discord, hopefully see you there! https://dsccommunity.org/community/contact/

And thanks for the link to the other person wanting an LCM replacement, lovely to hear that I'm not the only one!

Borgquite avatar Jun 23 '25 14:06 Borgquite

Update to the variant for $PSBoundParameters. After having multiple discussions and even more extensive testing, the approach using the SetPsBoundParameters method is the most suitable one for backwards compatibility.

It checks if a property is not null and considers it "bound" if it contains a value. If it's null, it's unbound. This requires the following change:

  • All value types (System.Int32, System.Boolean etc.) require an additional [Nullable[<inner Type>]] declaration in the parameter so that they can be omitted.

If a value type is omitted, it'll default to $null, which is "unbound" and therefore not considered for Get/Set/Test. Objects are null if omitted and cannot be distinguished between being not set and set to $null. Since this is very rarely the case (if even), this special case can be skipped. Usually, complex objects (using CIMInstance) are arrays which can be set to an empty array to be cleared or checked, and omitted (which leads to the array being null).

What do you think? Something we can move forward with?

FabienTschanz avatar Jun 25 '25 21:06 FabienTschanz

This reminds me of the issues we see in the Graph SDK where it's not possible to 'clear' an attribute by setting it to null. I tend to think that this may be an issue in DSC as well since it's a valid config to ensure that an attribute is 'not set'.

salbeck-sit avatar Jun 26 '25 06:06 salbeck-sit

@FabienTschanz I'm nowhere near experienced enough in the PowerShell internals here to be able to comment on whether this is a good way ahead, but since you've reached out to the #dsc guys, hopefully it will.

(Only other thought is you could try out the #fringe channel to see if they have any more ideas/concerns)

Borgquite avatar Jun 26 '25 10:06 Borgquite

@salbeck-sit I agree and disagree with you on it. On one hand, it would be great to ensure that a property is not set by configuring it's value to $null. But on the other hand, $null may not be a supported value and clearing a value after it has been set is not possible. Do you have any examples of those properties that you can clear with $null and they in fact are reset to their default value? Because from my experience, setting boolean values or values with a 0 or 1 value will, after once being set, always have a value and cannot be reset to "nothing".

FabienTschanz avatar Jun 26 '25 11:06 FabienTschanz

The various MgUser attributes like Office, MobilePhone etc can be reset to $null, if you know the trick how:

https://github.com/microsoftgraph/msgraph-sdk-powershell/issues/833#issuecomment-1294082532

Borgquite avatar Jun 26 '25 11:06 Borgquite

That's in my opinion just a hacky way to reset, I'd ensure that they are empty. There might be a trick coming to .NET 10 or 11 with a discriminated union type, which can be one of x types. Maybe PowerShell Core will adopt this feature in the future. But as long as we support the LCM, it'll only be possible to set them to an empty string and not null.

FabienTschanz avatar Jun 26 '25 11:06 FabienTschanz

@FabienTschanz With MgUser the Graph module can't set them to empty or null, without the workaround.

Borgquite avatar Jun 26 '25 12:06 Borgquite

Okay, so after trying pretty much everything out, I was finally able to make it work 99%. The only thing that's not possible is explicitly set null properties - For those properties that require this, we would need to introduce a dedicated property e.g. Default or just plain null (or even Unset to indicate that this will be set to its default value).

I was also able to identify why the properties set through the LCM were not picked up by the script property: It is using reflection to directly update the backing field... Instead of just using a "hashtable to class conversion", which would work really nicely or just a property accessor just as DSCv2 is doing. That would make it so much easier, but yeah... Since there is no "tracking information" like $PSBoundParameters available in a class, I implemented my own logic, but with the disadvantage that explicit $null values are not being captured. Although I think that this is a VERY niche case which we should only target explicitly for those very few properties that actually require it.

The following base resource is the key to it:

class BaseResource
{
    [DscProperty(NotConfigurable)]
    hidden [hashtable] $PSBoundParameters = @{}
    hidden static [bool] $IsInitialized = $false
    hidden [hashtable] $MyInvocation = @{}

    [void]SetProperty([string]$Name, $Value)
    {
        if ($this.PSBoundParameters.ContainsKey($Name)) {
            $this.PSBoundParameters[$Name] = $Value
        } else {
            $this.PSBoundParameters.Add($Name, $Value)
        }
    }

    [object]GetProperty([string]$Name)
    {
        if ($this.PSBoundParameters.ContainsKey($Name)) {
            return $this.PSBoundParameters[$Name]
        } else {
            return $null
        }
    }

    [void] UpdateTrackingInformation()
    {
        $classProperties = $this.GetType().GetProperties() | Where-Object { $_.Name -notin @('PSBoundParameters', 'IsInitialized', 'MyInvocation') }
        $classMethods = $this.GetType().GetMethods() | Where-Object { $_.Name -like "get_*" -and ($_.Name -notin @('get_PSBoundParameters', 'get_IsInitialized', 'get_MyInvocation')) }
        foreach ($property in $classProperties) {
            $method = $classMethods | Where-Object { $_.Name -eq "get_$($property.Name)" }
            $backingValue = (([System.Reflection.MethodInfo]$method).Invoke($this, $null))
            if ($this.PSBoundParameters[$property.Name] -ne $backingValue) {
                $this.PSObject.Properties[$($property.Name)].Value = $backingValue
            }
        }
    }

    BaseResource()
    {
        if (-not [BaseResource]::IsInitialized)
        {
            $classType = $this.GetType()
            $classProperties = $classType.GetProperties() | Where-Object { $_.Name -notin @('PSBoundParameters', 'IsInitialized') }
            $memberDefinitions = @()
            foreach ($property in $classProperties) {
                $memberDefinitions += @{
                    MemberType  = 'ScriptProperty'
                    MemberName  = $property.Name
                    Value = [ScriptBlock]::Create("`$this.GetProperty('$($property.Name)')") # Getter
                    SecondValue = [ScriptBlock]::Create("`$this.SetProperty('$($property.Name)', `$args[0])")
                }
            }

            foreach ($Definition in $memberDefinitions) {
                Update-TypeData -TypeName $classType.Name @Definition -Verbose
            }

            [BaseResource]::IsInitialized = $true
        }
    }
}

The methods Get-Property and Set-Property are just class methods that wrap a custom PSBoundParameters hashtable so that we do not need to update any of our scripts that use it. We can just continue using them without any changes. The method UpdateTrackingInformation however contains some key logic: It checks the backing field through reflection (which will be set from the LCM), and if that field differs from the initial value, it will add it to the bound parameters hashtable. While this change requires that value types, which will always be initialized from the get-go with their default value, need to be updated to be nullable (that's a simple replacement of e.g. [System.Int32] with [System.Nullable[System.Int32]], it allows us to catch EVERYTHING except explicit $null for reference types, which are by default $null.

I wrote a custom implementation of the IntuneDeviceCleanupRuleV2 (called it V3), which runs perfectly fine that way and catches all parameters without any issues. I'm honestly confident that we could move forward with this solution. A little bit of tweaking might be necessary, but with this being most likely the biggest change ever, I think we could bring this module to a whole new level, also in terms of performance.

One thing I just remembered: The UpdateTrackingInformation is only necessary in Windows PowerShell (or better said: when the LCM is running). So we could even put this behind an if statement and make it even more robust for future plans of DSCv3 to not interfere with the "legacy" and "old/outdated" code.

Also, just a note: The LCM is built on .NET 4.5, which is out of support since 2022. Honestly, we should move away from it.

If somebody is interested: The repositories with the entire code to reverse engineer are here:

A local disassembly of the Microsoft.Windows.DSC.CoreConfProviders.dll is required too, e.g. with JetBrains dotPeek. This is the entry point which provides the Invoke-DscResource cmdlet.

FabienTschanz avatar Aug 18 '25 21:08 FabienTschanz

@FabienTschanz I'm going to be honest and say that I'm not good enough with .NET or PowerShell to comment on your solution either way, except that what I think the outcome is, sounds good.

If I understand correctly, with the above code, we can move to class-based resources with a few minor changes to parameter types (adding IsNullable) but basically still using $PSBoundParameters so that we can find out whether parameters were set by the end-user, or just at the default.

I presume this still works for determining if a parameter is set with a 'default' value in the function definition, vs. user-set value?

And if I understand correctly, the only scenario where that's an issue is where a DSC resource accepts a parameter is explicitly set to $null? I am struggling to think of a DSC resource that relies on this behaviour, and case-by-case workarounds should be possible (another option depending on underlying cmdlets might be empty string '' along with the use of IsNullOrEmpty?)

Borgquite avatar Aug 19 '25 09:08 Borgquite

@FabienTschanz I've posted to the #dsc Discord channel and there are some comments there

Borgquite avatar Aug 19 '25 09:08 Borgquite

General

Consider using a prefix for some of your properties (such as _) to reduce the chance of possible conflicts with properties declared by inheriting types.

Do we need NotConfigurable for PSBoundParameters? Is this here to expose it to the caller?

IsInitialized must be an instance property not a static. If it's static, only the first class that inherits from this resource in a session will initialise. This matters in a scenario where there are multiple disparate resources inheriting within a single PS session. Alternatively you could track which classes you've initialised for in a static (for example in a HashSet<Type> property).

Consider making PSBoundParameters a Dictionary<string,PSObject> so you can use the Try methods instead of .Contains. A marginal performance gain.

MyInvocation would be better defined as another class modelled after a subset of InvocationInfo rather than a Hashtable with entirely arbitrary content.

SetProperty

In SetProperty, .Contains is arguably redundant. $this.PSBoundParameters[$Name] = $Value will both create a new key and change an existing key.

GetProperty

In GetProperty, if a Dictionary is used TryGetValue would be substituted to save the double key lookup on .Contains and value access. Completely trivial potential performance gain.

UpdateTrackingInformation

UpdateTrackingInformation requires documentation on when it would be called. Is it implicitly called in DSC 2/3?

Assign .GetType() to a variable and save yourself the extra method call.

You're limited to PS 5 so you may as well use the PS 3 syntax for Where-Object where possible. Neater and faster.

        $type = $this.GetType()
        $classProperties = $type.GetProperties() | Where-Object Name -notin 'PSBoundParameters', 'IsInitialized', 'MyInvocation'

You may elect to make this more flexible / easier to maintain by using:

        $classProperties = $type.GetProperties() | Where-Object Name -notin ([BaseBaseResource].PSObject.Properties.Name)

It may be worth considering caching the discovered properties and methods. However, this would have to be an instance member so you'd have to repeat on each instantiated instance of a type anyway. I'd still be tempted to expose something like a ResourceClassInfo or something on BaseResource so you don't have to add an endless set of properties to support this.

You could replace this:

                $this.PSObject.Properties[$($property.Name)].Value = $backingValue

with this:

                $this.($property.Name) = $backingValue

BaseResource constructor

See the note at the start about a static on BaseResource being inappropriate for this condition. You're updating in the base class on behalf of an inheriting type. Note that while statics are accessible via an inheriting class type, any change to the value will reflect on the declaring type.

The second loop is pointless extra work and should be culled:

            foreach ($property in $classProperties) {
                $definition = @{
                    MemberType  = 'ScriptProperty'
                    MemberName  = $property.Name
                    Value = [ScriptBlock]::Create("`$this.GetProperty('$($property.Name)')") # Getter
                    SecondValue = [ScriptBlock]::Create("`$this.SetProperty('$($property.Name)', `$args[0])")
                }
                Update-TypeData -TypeName $classType.Name @definition
            }

indented-automation avatar Aug 19 '25 10:08 indented-automation

@indented-automation Thanks a bunch, I will see what improvements with your suggestions I can make without breaking things. The LCM is quite picky on a couple of things, but I do understand on which points you're getting at. The focus was primarily to prove that it is possible, and now it's time to clean up and improve the code. Also, I don't know if this approach will actually be adopted. But if it is, that'd be really cool.

Do we need NotConfigurable for PSBoundParameters? Is this here to expose it to the caller?

No it's not. That's most likely a leftover of when I tried in a wild attempt with [DscResource()] on the base resource. I'll omit it for the next iteration.

MyInvocation would be better defined as another class modelled after a subset of InvocationInfo rather than a Hashtable with entirely arbitrary content.

True, this is one part I still need to work on. So thanks for the suggestion.

Consider making PSBoundParameters a Dictionary<string,PSObject> so you can use the Try methods instead of .Contains. A marginal performance gain.

Have to check if that will still work with what we're doing with PSBoundParameters elsewhere in the code. Again thanks for the suggestion here.

UpdateTrackingInformation requires documentation on when it would be called. Is it implicitly called in DSC 2/3?

UpdateTrackingInformation is only required in PS5.1 or better said only when it's invoked through the LCM. This would most likely be handled in the Get, Set and Test methods with an if condition. If you have any other idea, I'm always open for them.

I have one last question if you may:

I'd still be tempted to expose something like a ResourceClassInfo or something on BaseResource so you don't have to add an endless set of properties to support this.

So you mean that we provide a ResourceClassInfo with all of the exported properties, right? There is something similar as a big schema file already present in M365DSC, so we could use that and get the properties from there without needing a type lookup.

Thanks again, appreciate the answer and insights. I'm still learning and everything, so always happy and excited to learn from more experienced people 😄

FabienTschanz avatar Aug 19 '25 11:08 FabienTschanz

@Borgquite

I presume this still works for determining if a parameter is set with a 'default' value in the function definition, vs. user-set value?

Great input, will have to check this. I am not really sure at the moment, will get back to you on that. But I think that's possible yes.

And if I understand correctly, the only scenario where that's an issue is where a DSC resource accepts a parameter is explicitly set to $null? I am struggling to think of a DSC resource that relies on this behaviour, and case-by-case workarounds should be possible (another option depending on underlying cmdlets might be empty string '' along with the use of IsNullOrEmpty?)

True, the only scenario is where a parameter is explicitly set to $null. If I'm not mistaken, some of the EXO properties can be reset (or unset) using $null, but they are very limited. And you mentioned that this behaviour is the same for some of the properties on a Graph user. https://github.com/microsoft/Microsoft365DSC/issues/6202#issuecomment-3008133035

FabienTschanz avatar Aug 19 '25 11:08 FabienTschanz

This part isn't going to work out:

            if ($this.PSBoundParameters[$property.Name] -ne $backingValue) {

You're blindly assuming you can actually compare those values. It's way to primitive to be any use and instead you should just avoid acting on null (assuming that still meets your needs). It falls apart if the two values are arrays (for instance).

I've tweaked it "a bit" in the example at the bottom.

So you mean that we provide a ResourceClassInfo with all of the exported properties, right?

Your use of the static property allowed you to confine a bunch of operations to the instantiation of the first instance of a given type which I think was a good idea in principal. Given that we're just doing a bunch of reflection on the class, and that we only need update type data once, I think there's still benefit in confining that.

For example, if I (for whatever reason) am importing 500 instances of the same type in a single PS session, it'd be nice if I could just run analysis for that type one time only.

You'll need to save this as a script to test it.

using namespace System.Collections.Generic
using namespace System.Reflection

class ResourceInfo {
    hidden static $_baseProperties = [BaseResource].GetProperties().Name

    [Type] $Type
    [PropertyInfo[]] $Properties

    ResourceInfo([Type] $type) {
        $this.Type = $type
        $this.Properties = $type.GetProperties() | Where-Object Name -notin ([ResourceInfo]::_baseProperties)

        foreach ($property in $this.Properties) {
            $definition = @{
                MemberType  = 'ScriptProperty'
                MemberName  = $property.Name
                Value       = [ScriptBlock]::Create('$this._GetProperty("{0}")' -f $property.Name)
                SecondValue = [ScriptBlock]::Create('$this._SetProperty("{0}", $args[0])' -f $property.Name)
                Force       = $true
            }
            Update-TypeData -TypeName $this.Type.Name @definition
        }
    }
}

class BaseResource {
    hidden [Dictionary[string,object]] $PSBoundParameters =
        [Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase)

    hidden static [Dictionary[Type,ResourceInfo]] $_initialized =
        [Dictionary[Type,ResourceInfo]]::new()

    hidden [ResourceInfo] $_resourceInfo

    BaseResource() {
        $type = $this.GetType()
        $resourceInfo = $null
        if (-not [BaseResource]::_initialized.TryGetValue($type, [ref]$resourceInfo)) {
            [BaseResource]::_initialized[$type] = $type
            $resourceInfo = [BaseResource]::_initialized[$type]
        }

        $this._resourceInfo = $resourceInfo
        $this.BindDefaultValues()
    }

    hidden [object] _GetProperty([string] $name) {
        $value = $null
        $this.PSBoundParameters.TryGetValue($name, [ref]$value)
        return $value
    }

    hidden [void] _SetProperty([string] $name, [object] $value) {
        $this.PSBoundParameters[$name] = $value
    }

    hidden [void] BindDefaultValues() {
        foreach ($property in $this._resourceInfo.Properties) {
            $backingValue = $property.GetMethod.Invoke($this, $null)
            if ($null -eq $backingValue) {
                continue
            }
            $this.($property.Name) = $backingValue
        }
    }
}

class MyResource1 : BaseResource {
    [string] $a
    [string] $b
    [string] $c
}

class MyResource2 : BaseResource {
    [string] $x
    [Nullable[int]] $y
    [string[]] $z
}

class MyResource3 : BaseResource {
    [string] $x = 'default'
}

# Note that these property values are set *after* the ctor is called.
$a = [MyResource1]@{
    a = '1'
    b = '2'
}
$a | Out-Host

$b = [MyResource1]@{
    b = '3'
}
$b | Out-Host

$c = [MyResource2]::new()
$c.x = '4'
$c.y = 5
$c | Out-Host

$d = [MyResource2]@{
    z = '6', '7', '8'
}
$d | Out-Host

$e = [MyResource3]::new()
$e | Out-Host

edit updated with default value handling.

indented-automation avatar Aug 19 '25 12:08 indented-automation