This guide assumes a domain-joined Windows or Linux host with the RSAT / ActiveDirectory module installed (Add-WindowsCapability -Online -Name Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0 on Windows, or Install-Module PSPKI patterns on Linux using LDAP), and PowerShell 7.

This is post 1 of the Active Directory at Scale series. The rest of the series OUs and ACLs as code, Group Policy as code, WMI filters as code, certificate automation, security baselines, and debloating all reuse the pattern in this post.

Most Active Directory scripts in the wild fail the re-run test. They work the first time someone executes them on a clean forest, then blow up with Duplicate object or Object not found the second time. Or worse: they silently do the wrong thing because the operator "forgot" that last week's cleanup script already ran.

The property that fixes this is idempotency. A script is idempotent if running it N times has the same effect as running it once. Every configuration management tool in the industry (Ansible, Chef, Puppet, DSC, Terraform) is built around this single property, because it's what makes automation safe to rerun on a cron, safe to dry run in a pull request, and safe to hand to a less experienced operator.

AD specific automation is almost never written this way, but it can be, without the weight of a full configuration manager. The rest of this post defines what idempotency actually requires of a script, then introduces a disciplined three-function pattern (Get-Test-Set) that delivers those properties. Every subsequent post in the series OUs and ACLs, Group Policy, WMI filters, certificates, security baselines, cleanup is just this pattern applied to a different object type.

The Problem With "Just Script It"

Here's a representative one liner from a runbook I inherited once:

New-ADGroup -Name 'App-FinanceReports-RW' -GroupScope DomainLocal -Path 'OU=Groups,DC=corp,DC=example,DC=com'

It works on Tuesday. On Wednesday the help-desk manually creates the group (because they missed the automation ticket). On Thursday the script runs again:

New-ADGroup : The specified group already exists

Two classes of fix:

  1. try/catch and swallow the specific exception. Don't. You've just made the script silent about a real state mismatch.
  2. Check first, act second, and structure the script so that check/act is the unit of composition and not the whole script.

The second option is what every idempotent automation framework does. Let's pin down what "idempotent" actually demands before writing any code.

What Idempotency Actually Requires

A script is idempotent when it satisfies all of the following and not just the first one, which is where most posts stop:

  1. Safe to rerun. The second invocation on unchanged state produces no side effects and no errors.
  2. Convergent, not assumptive. It doesn't require a specific starting state. It observes the world, then drives it to the desired state from wherever it happens to be.
  3. Honest about what changed. Output distinguishes "noop" from "changed X" with enough detail that a CI log makes sense in six months.
  4. Dry-runnable. A -WhatIf invocation reports exactly what the apply invocation would do, without actually doing it.
  5. Composable. The unit of idempotency is small enough that hundreds of them run in sequence. If one piece isn't safe to rerun, the whole pipeline isn't.
  6. Deterministic. Same inputs, same outcome regardless of which DC you talk to, which weekday it is, or what random order the underlying cmdlets happen to return objects in.

"Try again next time" is not an acceptable fallback for any of these. An AD script that drifts if you run it twice in the same hour is broken, even if neither run threw.

The Pattern Get, Test, Set

There are several ways to satisfy those requirements. Ansible hides it inside modules, Chef inside resources, Terraform inside providers. Microsoft's answer is PowerShell DSC, and DSC uses the exact same three-verb shape as this post: every DSC resource (function-based or class-based) is required to expose Get-TargetResource, Test-TargetResource, and Set-TargetResource. The Local Configuration Manager calls Test, and only calls Set if Test returns $false. That's not a coincidence with what follows, it's the same contract, just without the DSC runtime wrapped around it.

The version I use for hand-written PowerShell against AD is the same shape in three plain functions:

Verb Job Calls Side effects?
Get- Return current state. Missing is not an error, it's an empty shape. nothing Never
Test- Call Get-*, compare to desired, return { Compliant, Drifts, Current }. Get-* Never
Set- Call Test-*, act only on drift, honor -WhatIf. Test-* Yes

The contract is non-negotiable:

  • Get-* never modifies anything and never calls anyone else.
  • Test-* never modifies anything. It's responsible for reading state (via Get-*) and classifying drift. The return shape always carries the current state it read, so Set-* does not have to re-read.
  • Set-* is the only place mutation happens, and only inside a ShouldProcess guard. It calls Test-* once, and bases every decision on the result.

This chain (Set→Test→Get) means a full reconcile does exactly one state read per object. It also makes Test-* a useful standalone cmdlet: pipe desired-state objects in, collect non-compliant results, ship to an alert channel. Drift detection is the same code path as apply, just with nothing downstream.

Every AD automation task in the rest of this series is one or more (Get, Test, Set) triples built on this contract.

A Worked Example a Security Group

The "desired state" we want to express: a domain-local security group named App-FinanceReports-RW in OU=Groups,…, with a specific description, scope, and set of members.

Step 1 Get-

function Get-DesiredGroupState
{
    <#
    .SYNOPSIS
        Returns the current state of an Active Directory group.

    .DESCRIPTION
        Reads the current attributes of an AD group identified by Identity
        under the supplied SearchBase. A missing group is NOT an error -
        an object with Exists = $false is returned instead so downstream
        Test-* / Set-* functions can rely on a uniform shape.

    .PARAMETER Identity
        The sAMAccountName (or Name) of the group to look up.

    .PARAMETER SearchBase
        Distinguished name of the OU that scopes the search.

    .EXAMPLE
        Get-DesiredGroupState -Identity 'App-FinanceReports-RW' -SearchBase 'OU=Groups,DC=corp,DC=example,DC=com'

    .OUTPUTS
        PSCustomObject
    #>
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Identity,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $SearchBase
    )

    begin
    {
        Write-Verbose -Message ('[Get-DesiredGroupState] Begin')
    }

    process
    {
        Write-Verbose -Message ('[Get-DesiredGroupState] Looking up {0} under {1}' -f $Identity, $SearchBase)

        $groupParams = @{
            Filter      = "Name -eq '$Identity'"
            SearchBase  = $SearchBase
            Properties  = 'Description', 'GroupScope', 'GroupCategory', 'DistinguishedName'
            ErrorAction = 'SilentlyContinue'
        }

        $group = Get-ADGroup @groupParams | Select-Object -First 1

        if (-not $group)
        {
            Write-Verbose -Message ('[Get-DesiredGroupState] {0} not found; returning empty state' -f $Identity)

            return [pscustomobject] @{
                Exists            = $false
                Name              = $Identity
                DistinguishedName = $null
                Description       = $null
                GroupScope        = $null
                GroupCategory     = $null
                Members           = @()
            }
        }

        $members = Get-ADGroupMember -Identity $group.DistinguishedName -ErrorAction SilentlyContinue |
            Select-Object -ExpandProperty DistinguishedName |
            Sort-Object

        [pscustomobject] @{
            Exists            = $true
            Name              = $group.Name
            DistinguishedName = $group.DistinguishedName
            Description       = $group.Description
            GroupScope        = $group.GroupScope.ToString()
            GroupCategory     = $group.GroupCategory.ToString()
            Members           = @($members)
        }
    }

    end
    {
        Write-Verbose -Message ('[Get-DesiredGroupState] End')
    }
}

Two rules followed:

  • ErrorAction SilentlyContinue on the lookup so "not found" returns an empty-state object instead of throwing. Missing is not an error.
  • The shape is the same whether the object exists or not. Downstream functions can rely on Exists, Members, etc. without null-checking.

Wait, you will say, ErrorAction SilentlyContinue is usually a code smell. Not here. In Get-Test-Set, "object is absent" is a valid observable state, not an exceptional one. The exception handling happens at the Set-* layer.

Step 2 Test-

function Test-GroupCompliance
{
    <#
    .SYNOPSIS
        Reads the current state of an AD group and compares it to a desired-state object.

    .DESCRIPTION
        Never mutates. Calls Get-DesiredGroupState internally so callers never
        need to read state themselves, and returns the current state on the
        result object so Set-* does not have to re-read. Drifts enumerates the
        specific mismatches for logs and alerts.

    .PARAMETER Desired
        The desired state expressed by the caller (usually a YAML entry).
        Must carry Name, GroupScope, GroupCategory, Description, and Members.

    .PARAMETER SearchBase
        Distinguished name of the OU that scopes the Get-* read.

    .OUTPUTS
        System.Management.Automation.PSCustomObject
    #>
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [pscustomobject]
        $Desired,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $SearchBase
    )

    begin
    {
        Write-Verbose -Message ('[Test-GroupCompliance] Begin')
    }

    process
    {
        $current = Get-DesiredGroupState -Identity $Desired.Name -SearchBase $SearchBase
        $drifts  = New-Object -TypeName 'System.Collections.Generic.List[string]'

        if (-not $current.Exists)
        {
            $drifts.Add('Group does not exist')

            return [pscustomobject] @{
                Compliant = $false
                Drifts    = $drifts.ToArray()
                Current   = $current
            }
        }

        if ($current.GroupScope -ne $Desired.GroupScope)
        {
            $drifts.Add(("GroupScope: '{0}' != '{1}'" -f $current.GroupScope, $Desired.GroupScope))
        }

        if ($current.GroupCategory -ne $Desired.GroupCategory)
        {
            $drifts.Add(("GroupCategory: '{0}' != '{1}'" -f $current.GroupCategory, $Desired.GroupCategory))
        }

        if (($current.Description ?? '') -ne ($Desired.Description ?? ''))
        {
            $drifts.Add('Description drift')
        }

        $extra   = $current.Members | Where-Object -FilterScript { $_ -notin $Desired.Members }
        $missing = $Desired.Members | Where-Object -FilterScript { $_ -notin $current.Members }

        if ($extra)
        {
            $drifts.Add(('Extra members: {0}' -f ($extra -join ', ')))
        }

        if ($missing)
        {
            $drifts.Add(('Missing members: {0}' -f ($missing -join ', ')))
        }

        [pscustomobject] @{
            Compliant = $drifts.Count -eq 0
            Drifts    = $drifts.ToArray()
            Current   = $current
        }
    }

    end
    {
        Write-Verbose -Message ('[Test-GroupCompliance] End')
    }
}

A few points worth calling out:

  • Test- calls Get- internally. The caller passes Desired and SearchBase; state is read once, inside Test. Set doesn't re-read.
  • Return the current state. Attaching Current to the result means downstream Set-* has everything it needs to decide and act without a second AD round-trip.
  • Return a reason, not just a boolean. $drifts is what makes the output useful in a CI log. "Compliant: False" without context is useless at 2 AM.
  • Sort or set-compare member lists. Order sensitivity is a trap with AD because the underlying attribute is a multi-value blob.
  • Null vs empty string. AD returns $null for an unset Description; a desired state of '' should compare equal. ?? makes that explicit.

The null coalescing operator ?? only works in Powershell Core (7+). If you're running powershell 5.1, use [string]::IsNullOrEmpty() or [string]::IsNullOrWhiteSpace()

Step 3 Set-

function Set-GroupCompliance
{
    <#
    .SYNOPSIS
        Converges an AD group to match the desired-state object.

    .DESCRIPTION
        The only function in the triple that mutates AD. Calls Test-* once
        to read current state + classify drift, then acts only when drift
        is detected. Never reads state itself, never reads it twice. Every
        mutation goes through ShouldProcess so -WhatIf produces a complete
        dry-run.

    .PARAMETER Desired
        The desired state object (usually a YAML entry). Must carry Name,
        GroupScope, GroupCategory, Description, and Members.

    .PARAMETER SearchBase
        Distinguished name of the OU the group lives (or will live) in.

    .EXAMPLE
        Set-GroupCompliance -Desired $g -SearchBase 'OU=Groups,DC=corp,DC=example,DC=com' -WhatIf

    .OUTPUTS
        PSCustomObject
    #>
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    [OutputType([pscustomobject])]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [pscustomobject]
        $Desired,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $SearchBase
    )

    begin
    {
        Write-Verbose -Message ('[Set-GroupCompliance] Begin')
    }

    process
    {
        $ErrorActionPreference = 'Stop'

        $test    = Test-GroupCompliance -Desired $Desired -SearchBase $SearchBase
        $current = $test.Current

        if ($test.Compliant)
        {
            Write-Verbose -Message ('[Set-GroupCompliance] {0} already compliant' -f $Desired.Name)

            return [pscustomobject] @{
                Name    = $Desired.Name
                Changed = $false
                Actions = @()
                Errors  = @()
            }
        }

        $actions = New-Object -TypeName 'System.Collections.Generic.List[string]'
        $errors  = New-Object -TypeName 'System.Collections.Generic.List[string]'

        if (-not $current.Exists)
        {
            if ($PSCmdlet.ShouldProcess($Desired.Name, 'Create group'))
            {
                $newParams = @{
                    Name          = $Desired.Name
                    Path          = $SearchBase
                    GroupScope    = $Desired.GroupScope
                    GroupCategory = $Desired.GroupCategory
                    Description   = $Desired.Description
                    ErrorAction   = 'Stop'
                }

                try
                {
                    $null = New-ADGroup @newParams
                    $actions.Add('Created group')
                }
                catch [Microsoft.ActiveDirectory.Management.ADException]
                {
                    $errors.Add(('New-ADGroup failed for {0}: {1}' -f $Desired.Name, $_.Exception.Message))
                    Write-Error -ErrorRecord $_
                    throw
                }
                catch
                {
                    $errors.Add(('Unexpected error creating {0}: {1}' -f $Desired.Name, $_.Exception.Message))
                    throw
                }
            }
        }
        else
        {
            $setParams = @{
                Identity = $current.DistinguishedName
            }

            if ($current.GroupScope -ne $Desired.GroupScope)
            {
                $setParams.GroupScope = $Desired.GroupScope
                $actions.Add('GroupScope updated')
            }

            if ($current.GroupCategory -ne $Desired.GroupCategory)
            {
                $setParams.GroupCategory = $Desired.GroupCategory
                $actions.Add('GroupCategory updated')
            }

            if (($current.Description ?? '') -ne ($Desired.Description ?? ''))
            {
                $setParams.Description = $Desired.Description
                $actions.Add('Description updated')
            }

            if ($setParams.Count -gt 1 -and
                $PSCmdlet.ShouldProcess($Desired.Name, 'Update group attributes'))
            {
                try
                {
                    Set-ADGroup @setParams -ErrorAction Stop
                }
                catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException]
                {
                    $errors.Add(('Set-ADGroup target {0} disappeared mid-run; replication or concurrent delete' -f $Desired.Name))
                    throw
                }
                catch [Microsoft.ActiveDirectory.Management.ADException]
                {
                    $errors.Add(('Set-ADGroup failed for {0}: {1}' -f $Desired.Name, $_.Exception.Message))
                    throw
                }
                catch
                {
                    $errors.Add(('Unexpected error updating {0}: {1}' -f $Desired.Name, $_.Exception.Message))
                    throw
                }
            }

            # Membership drift
            $extra   = $current.Members | Where-Object -FilterScript { $_ -notin $Desired.Members }
            $missing = $Desired.Members | Where-Object -FilterScript { $_ -notin $current.Members }

            foreach ($m in $extra)
            {
                if ($PSCmdlet.ShouldProcess(('{0} -> {1}' -f $Desired.Name, $m), 'Remove member'))
                {
                    try
                    {
                        Remove-ADGroupMember -Identity $current.DistinguishedName -Members $m -Confirm:$false -ErrorAction Stop
                        $actions.Add(('Removed {0}' -f $m))
                    }
                    catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException]
                    {
                        # Already absent; treat as converged but record for the audit log.
                        $errors.Add(('Member {0} already absent from {1} (no-op)' -f $m, $Desired.Name))
                        Write-Verbose -Message $errors[-1]
                    }
                    catch [Microsoft.ActiveDirectory.Management.ADException]
                    {
                        $errors.Add(('Failed to remove {0} from {1}: {2}' -f $m, $Desired.Name, $_.Exception.Message))
                        throw
                    }
                    catch
                    {
                        $errors.Add(('Unexpected error removing {0} from {1}: {2}' -f $m, $Desired.Name, $_.Exception.Message))
                        throw
                    }
                }
            }

            foreach ($m in $missing)
            {
                if ($PSCmdlet.ShouldProcess(('{0} -> {1}' -f $Desired.Name, $m), 'Add member'))
                {
                    try
                    {
                        Add-ADGroupMember -Identity $current.DistinguishedName -Members $m -ErrorAction Stop
                        $actions.Add(('Added {0}' -f $m))
                    }
                    catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException]
                    {
                        # The principal hasn't replicated to this DC yet; rethrow so Invoke-AdRetry can back off.
                        $errors.Add(('Principal {0} not yet visible when adding to {1} (replication lag)' -f $m, $Desired.Name))
                        throw
                    }
                    catch [Microsoft.ActiveDirectory.Management.ADException]
                    {
                        $errors.Add(('Failed to add {0} to {1}: {2}' -f $m, $Desired.Name, $_.Exception.Message))
                        throw
                    }
                    catch
                    {
                        $errors.Add(('Unexpected error adding {0} to {1}: {2}' -f $m, $Desired.Name, $_.Exception.Message))
                        throw
                    }
                }
            }
        }

        [pscustomobject] @{
            Name    = $Desired.Name
            Changed = $actions.Count -gt 0
            Actions = $actions.ToArray()
            Errors  = $errors.ToArray()
        }
    }

    end
    {
        Write-Verbose -Message ('[Set-GroupCompliance] End')
    }
}

Set-* is the only function that mutates, and every mutation lives in its own try/catch. Three rules hold the error-handling story together:

  • Force terminating errors. $ErrorActionPreference = 'Stop' at the top of process, plus -ErrorAction Stop on each AD cmdlet. AD cmdlets default to non-terminating, so without this the try/catch would never fire and a partial failure would silently look like success.
  • Catch from specific to general. ADIdentityNotFoundException first (because it has known recovery semantics: replication lag for adds, already-converged for removes), then the broader ADException, then a final catch for genuinely unexpected failures. Each branch records what was happening and what failed before rethrowing, so the call site sees a real exception and the result object's Errors array carries the human-readable context.
  • Re-throw everything you don't know how to handle. The only exception swallowed here is "trying to remove a member that's already gone" because that's the desired state. Every other path rethrows after recording the failure rationale, so the runner exits non-zero and the CI pipeline doesn't ship a green check on a half-applied change. Wrap the whole Set-* call site in Invoke-AdRetry (defined below) to convert replication-lag rethrows into a transparent retry; everything else surfaces as a real failure.

Reading with -WhatIf still produces a perfect dry-run because every mutation also goes through ShouldProcess:

$desired = [pscustomobject]@{
    Name          = 'App-FinanceReports-RW'
    Description   = 'Read/write access to Finance Reports share'
    GroupScope    = 'DomainLocal'
    GroupCategory = 'Security'
    Members       = @(
        'CN=alice,OU=Staff,DC=corp,DC=example,DC=com',
        'CN=bob,OU=Staff,DC=corp,DC=example,DC=com'
    )
}

Set-GroupCompliance -Desired $desired -SearchBase 'OU=Groups,DC=corp,DC=example,DC=com' -WhatIf -Verbose

Why This Triad Wins

  • You can re-run without fear. Whether the group exists, drifted, or was untouched, the outcome is deterministic.
  • Drift reporting is free. $desiredStates | Test-* -SearchBase $base | Where-Object { -not $_.Compliant } is your audit job. Because Test-* reads state itself, there's no separate Get-* call at the top of the pipeline.
  • Dry-runs are first class. Set-* with -WhatIf shows exactly what would change.
  • Composable. Want to reconcile 200 groups? It's a foreach, not a rewrite.

Structuring the Script as a Module

Each Get-Test-Set triple lives in its own .ps1 file; the module manifest (*.psd1) exports them. The Sampler post is the right starting layout.

src/
└── ADOps/
    ├── ADOps.psd1
    ├── ADOps.psm1
    ├── Public/
    │   ├── Get-DesiredGroupState.ps1
    │   ├── Test-GroupCompliance.ps1
    │   └── Set-GroupCompliance.ps1
    └── Private/
        └── ConvertTo-Dn.ps1

The same split applies to every object type we'll touch in the rest of the series: OUs, GPOs, certificate templates, security baselines.

Orchestration One Triple Is Easy, Fifty Is Not

Real reconciliation runs dozens of triples in sequence. The naive version is:

foreach ($g in $desiredGroups) { Set-GroupCompliance -Desired $g -SearchBase $base }
foreach ($o in $desiredOus)    { Set-OuCompliance    -Desired $o }
foreach ($a in $desiredAcls)   { Set-AclCompliance   -Desired $a }

That's fine until a downstream object depends on an upstream one (e.g. a group can't be added as an ACL principal if the group doesn't exist yet). Two patterns handle this:

  1. Topologically order your state. Process containers first (OUs), then the objects inside (groups, users), then relationships (ACLs, group memberships).
  2. Run the pipeline twice. Second pass picks up anything that failed to satisfy a dependency the first time around. If the second pass produces zero changes, you are converged.

The second pattern is what every real configuration manager (DSC, Ansible, Chef) uses under the hood. It is robust to most ordering bugs. It is also why idempotency is non-negotiable: if functions aren't safe to re-run, you cannot use this strategy at all.

Error Semantics The Subtle Part

AD is a distributed database. Writes don't always commit to the DC you talked to immediately, replication is asynchronous, and some operations (like creating a user and immediately adding them to a group) fall through the cracks.

Handle this at the Set- layer, not the Test- layer:

function Invoke-AdRetry
{
    <#
    .SYNOPSIS
        Retries an AD operation with exponential backoff on replication lag errors.

    .DESCRIPTION
        Smooths over the common case where a newly-created object hasn't
        replicated to the DC the next cmdlet targets. Catches only
        ADIdentityNotFoundException - broader catches would hide real
        logic errors.

    .PARAMETER Action
        Scriptblock to invoke. Its output is returned.

    .PARAMETER MaxAttempts
        Maximum number of attempts before rethrowing. Defaults to 5.

    .PARAMETER DelayMs
        Base delay between attempts in milliseconds. Actual delay scales
        linearly with attempt number.
    #>
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [scriptblock]
        $Action,

        [Parameter()]
        [int]
        $MaxAttempts = 5,

        [Parameter()]
        [int]
        $DelayMs = 500
    )

    begin
    {
        Write-Verbose -Message ('[Invoke-AdRetry] Begin (max {0} attempts)' -f $MaxAttempts)
    }

    process
    {
        for ($i = 1; $i -le $MaxAttempts; $i++)
        {
            try
            {
                return & $Action
            }
            catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException]
            {
                if ($i -eq $MaxAttempts)
                {
                    throw
                }

                Write-Verbose -Message ('[Invoke-AdRetry] Attempt {0}/{1} failed with NotFound; backing off' -f $i, $MaxAttempts)
                Start-Sleep -Milliseconds ($DelayMs * $i)
            }
        }
    }

    end
    {
        Write-Verbose -Message ('[Invoke-AdRetry] End')
    }
}

Wrap the specific AD cmdlet call in Invoke-AdRetry. Don't catch the generic Exception. The point of retry is to smooth over replication latency, not to hide broken logic.

Testing the Triple with Pester

Pester 5 lets you test the triple end-to-end against a lab DC (or a local AD LDS instance). The test structure mirrors the pattern:

BeforeAll {
    Import-Module $PSScriptRoot/../src/ADOps/ADOps.psd1 -Force
    $script:base = 'OU=PesterTest,DC=lab,DC=example,DC=com'
    $null = New-ADOrganizationalUnit -Name 'PesterTest' -Path 'DC=lab,DC=example,DC=com' -ProtectedFromAccidentalDeletion $false -ErrorAction SilentlyContinue
}

Describe 'Group idempotency' {

    It 'creates a group on first run' {
        $desired = [pscustomobject]@{
            Name = 'P-Test-Alpha'; Description = 'test'
            GroupScope = 'DomainLocal'; GroupCategory = 'Security'; Members = @()
        }
        $r = Set-GroupCompliance -Desired $desired -SearchBase $script:base
        $r.Changed | Should -BeTrue
    }

    It 'is a no-op on second run' {
        $desired = [pscustomobject]@{
            Name = 'P-Test-Alpha'; Description = 'test'
            GroupScope = 'DomainLocal'; GroupCategory = 'Security'; Members = @()
        }
        $r = Set-GroupCompliance -Desired $desired -SearchBase $script:base
        $r.Changed | Should -BeFalse
    }

    It 'detects drift on attribute change' {
        Set-ADGroup -Identity 'P-Test-Alpha' -Description 'drifted'
        $desired = [pscustomobject]@{
            Name = 'P-Test-Alpha'; Description = 'test'
            GroupScope = 'DomainLocal'; GroupCategory = 'Security'; Members = @()
        }
        $r = Test-GroupCompliance -Desired $desired -SearchBase $script:base
        $r.Compliant     | Should -BeFalse
        $r.Current.Exists | Should -BeTrue               # Test surfaces current state
        $r.Drifts        | Should -Contain 'Description drift'
    }
}

AfterAll {
    Get-ADGroup -Filter "Name -like 'P-Test-*'" -SearchBase $script:base |
        Remove-ADGroup -Confirm:$false -ErrorAction SilentlyContinue
    Remove-ADOrganizationalUnit -Identity $script:base -Recursive -Confirm:$false -ErrorAction SilentlyContinue
}

The critical test is the second one "no-op on second run" because that's what idempotency actually means. A pipeline that converges on one apply but makes changes on the next is not idempotent even if both apply steps "succeed".

Common Anti-Patterns

  • Using try/catch to hide "already exists" errors. You hide real problems this way. Check first.
  • Always re-setting every attribute. Ever-clobbering scripts churn replication traffic and audit logs. Set-* should be a no-op when state matches.
  • Implicit defaults in the script. If the script defaults GroupScope to Global but the YAML didn't say, a human later edits the script and breaks every group. Pass everything explicitly.
  • Catching non-terminating errors. AD cmdlets default to non-terminating. Set $ErrorActionPreference = 'Stop' at the top of the Set-* function body, not globally.
  • Modifying $Desired inside Set-*. The desired-state object is read-only. If you need to compute something from it, do it in a local variable.

The Runner Putting It Together

The runner should accept either YAML or JSON on disk. YAML is easier to author and review; JSON is easier to emit from other tooling (Terraform external data, CMDB exports, compiled from higher-level DSLs). A tiny dispatcher loads both into the same pscustomobject shape, so nothing downstream cares which format shipped:

function Import-DesiredState
{
    <#
    .SYNOPSIS
        Loads a YAML or JSON desired-state file into a PSCustomObject graph.

    .DESCRIPTION
        Dispatches on file extension. Both formats are normalized to
        PSCustomObject so Set-* functions can treat them interchangeably.
        YAML is round-tripped through JSON to convert the hashtable output
        of ConvertFrom-Yaml into the same PSCustomObject shape ConvertFrom-Json
        produces natively.

    .PARAMETER Path
        Path to a .yaml, .yml, or .json file.

    .OUTPUTS
        System.Management.Automation.PSCustomObject
    #>
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateScript({ Test-Path -Path $_ -PathType Leaf })]
        [string]
        $Path
    )

    process
    {
        $ext = [System.IO.Path]::GetExtension($Path).ToLowerInvariant()
        $raw = Get-Content -Path $Path -Raw

        switch ($ext)
        {
            '.json'
            {
                return $raw | ConvertFrom-Json
            }

            { $_ -in '.yaml', '.yml' }
            {
                if (-not (Get-Module -ListAvailable -Name 'powershell-yaml'))
                {
                    throw "The 'powershell-yaml' module is required for YAML input. Install with: Install-Module powershell-yaml -Scope CurrentUser"
                }

                Import-Module -Name 'powershell-yaml' -ErrorAction Stop
                return $raw | ConvertFrom-Yaml | ConvertTo-Json -Depth 20 | ConvertFrom-Json
            }

            default
            {
                throw ("Unsupported state-file extension '{0}'. Use .yaml, .yml, or .json." -f $ext)
            }
        }
    }
}

The runner then looks the same regardless of which format was handed in:

[CmdletBinding(SupportsShouldProcess)]
param
(
    [Parameter(Mandatory = $true)]
    [ValidateScript({ Test-Path -Path $_ -PathType Leaf })]
    [string]
    $StateFile
)

$ErrorActionPreference = 'Stop'
Import-Module ./src/ADOps/ADOps.psd1 -Force

$state   = Import-DesiredState -Path $StateFile
$results = New-Object -TypeName 'System.Collections.Generic.List[object]'

foreach ($g in $state.groups)
{
    $r = Set-GroupCompliance -Desired $g -SearchBase $state.searchBase
    $results.Add($r)
}

$changed = $results | Where-Object -FilterScript { $_.Changed }
Write-Host ('[reconcile] {0} objects, {1} changed' -f $results.Count, $changed.Count)
$changed | Format-Table Name, @{ N = 'Actions'; E = { $_.Actions -join '; ' } } -AutoSize

# Non-zero exit if anything changed (useful for CI drift gates)
if ($changed.Count -gt 0 -and -not $PSCmdlet.ShouldProcess('pipeline', 'mark drift'))
{
    exit 1
}

A state file in either format looks like this. The YAML version:

searchBase: OU=Groups,DC=corp,DC=example,DC=com
groups:
  - name: App-FinanceReports-RW
    description: Read/write access to Finance Reports share
    groupScope: DomainLocal
    groupCategory: Security
    members:
      - CN=alice,OU=Staff,DC=corp,DC=example,DC=com
      - CN=bob,OU=Staff,DC=corp,DC=example,DC=com

The JSON equivalent (exactly what Import-DesiredState hands back in memory):

{
  "searchBase": "OU=Groups,DC=corp,DC=example,DC=com",
  "groups": [
    {
      "name": "App-FinanceReports-RW",
      "description": "Read/write access to Finance Reports share",
      "groupScope": "DomainLocal",
      "groupCategory": "Security",
      "members": [
        "CN=alice,OU=Staff,DC=corp,DC=example,DC=com",
        "CN=bob,OU=Staff,DC=corp,DC=example,DC=com"
      ]
    }
  ]
}

Run with -WhatIf for a dry-run, or without for an apply. Wire the same script into a Gitea Actions or GitHub Actions job on a cron, and you have drift detection + remediation for free and the state file can be whichever format the author prefers.

What to Do Next

Get-Test-Set is boring, and that's the point. Four hundred well-structured Get-Test-Set triples across your directory outperform four thousand lines of one-shot migration scripts every time, because they reconcile, report, and recover on their own.

Three concrete moves to internalize the pattern:

  1. Pick the next AD change you would normally script as a one-shot (creating a group, modifying an ACL, fixing a typo on ten user accounts). Write it as a Get-Test-Set triple instead. The one-shot would have been twelve lines; the triple is fifty. The next time the same change is needed, the triple already exists.
  2. Run the Pester pattern from this post against a lab DC. The "no-op on second run" test is the one that proves the pattern is working; without it, you are writing scripts that look idempotent but aren't.
  3. Schedule the runner with -WhatIf on a cron even before you ship it as a real reconciler. Drift detection is the cheapest deliverable from this pattern, and it gives you a reason to keep the YAML accurate.

The rest of this series applies the same discipline to OUs and ACLs, Group Policy, WMI filters, certificates, security baselines, and cleanup of stale objects. Read them in order if you're new to the pattern; jump to whichever object type you're fighting with right now if you've already written your first triple.

If you only remember one thing: Get-* never throws, Test-* never mutates, Set-* is the only place anything changes, and it must be safe to run twice.