Public/Set-VmPowerSchedule.ps1

function Set-VmPowerSchedule {
    <#
    .SYNOPSIS
    Store a schedule in the catalogue, without a redeployment

    .DESCRIPTION
    Writes into PM_ScheduleCatalogCustom, which the deployment never touches. That split is the
    whole reason there are two variables: one owned by infrastructure as code, one owned by whoever
    is on the end of a phone at half past five - see docs/decisions/0005.

    A schedule with a name that already exists in the custom catalogue replaces it. A name that
    exists only in the deployment catalogue is shadowed rather than changed, so the shipped example
    stays intact and a later redeployment does not fight the override.

    Everything is validated before anything is written, and the write is one replacement of the
    whole variable rather than an append, so a half-written catalogue is not a state this can leave
    behind.

    .PARAMETER Schedule
    Schedules to store, from New-VmPowerSchedule or read back from a file.

    .PARAMETER ResourceGroupName
    Resource group holding the Automation Account.

    .PARAMETER AutomationAccountName
    The Automation Account.

    .PARAMETER SubscriptionId
    Subscription holding the account. Defaults to the current context.

    .PARAMETER PassThru
    Return the stored schedules.

    .EXAMPLE
    # The one-liner this whole design exists for
    New-VmPowerSchedule -Name office-hours-ch -TimeZone 'Europe/Zurich' -Weekdays '07:30-18:30' |
        Set-VmPowerSchedule -ResourceGroupName rg-vmpower -AutomationAccountName aa-vmpower

    .EXAMPLE
    # Look before you store
    $s = New-VmPowerSchedule -Name night-shift -TimeZone 'Europe/Zurich' -Daily '22:00-06:00'
    $s | Show-VmPowerScheduleCalendar -Days 3
    $s | Set-VmPowerSchedule -ResourceGroupName rg-vmpower -AutomationAccountName aa-vmpower

    .INPUTS
    AzureVMPowerManagement.VmPowerSchedule

    .OUTPUTS
    AzureVMPowerManagement.VmPowerSchedule when -PassThru is given, otherwise nothing

    .NOTES
    RequiredPermissions: Microsoft.Automation/automationAccounts/variables/write and /read on the
    Automation Account. Automation Contributor covers it; a custom role limited to those two actions
    is narrower and is what a person authoring schedules should have.

    Prerequisites: PowerShell 7.2 or later and Az.Accounts.

    Writes: The Automation variable PM_ScheduleCatalogCustom. It never writes PM_ScheduleCatalog,
    which belongs to the deployment.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType('AzureVMPowerManagement.VmPowerSchedule')]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [object[]]$Schedule,

        [Parameter(Mandatory)]
        [string]$ResourceGroupName,

        [Parameter(Mandatory)]
        [string]$AutomationAccountName,

        [Parameter()]
        [string]$SubscriptionId,

        [Parameter()]
        [switch]$PassThru
    )

    begin {
        $incoming = [System.Collections.Generic.List[object]]::new()
    }

    process {
        foreach ($entry in @($Schedule)) {
            if ($null -ne $entry) { $incoming.Add($entry) }
        }
    }

    end {
        if (-not $incoming.Count) { return }

        # Validate everything first. A run that stored three schedules and then threw on the fourth
        # would leave a catalogue nobody asked for.
        $checked = @($incoming | Test-VmPowerSchedule -Detailed)
        $bad = @($checked | Where-Object { -not $_.Valid })
        if ($bad.Count) {
            throw ("Nothing was stored. $($bad.Count) schedule(s) did not validate: " +
                (($bad | ForEach-Object { "$($_.Name): $($_.Problem)" }) -join ' | '))
        }

        $common = @{ ResourceGroupName = $ResourceGroupName; AutomationAccountName = $AutomationAccountName }
        if ($SubscriptionId) { $common['SubscriptionId'] = $SubscriptionId }
        $variable = $script:CatalogVariable.Custom

        $existing = [ordered]@{}
        foreach ($entry in (Get-VmPowerCatalogVariable @common -VariableName $variable)) {
            if ($null -eq $entry) { continue }
            $expanded = Expand-VmPowerSchedule -Schedule $entry
            $existing[$expanded.Name] = $expanded
        }

        # Not $schedule. PowerShell variable names are case-insensitive, so a loop variable spelled
        # that way IS this function's [object[]]$Schedule parameter, and every assignment to it is
        # silently coerced into a one-element array. The catalogue then serialised as a nested array
        # that the reader could only describe as "a schedule with no name". Found against a real
        # Automation Account on 2026-09-10; a typed parameter makes its own name unusable as a local.
        $replaced = @()
        $stored = [System.Collections.Generic.List[object]]::new()
        foreach ($result in $checked) {
            $expanded = $result.Schedule
            if ($existing.Contains($expanded.Name)) { $replaced += $expanded.Name }
            $existing[$expanded.Name] = $expanded
            $stored.Add($expanded)
        }

        $target = "$AutomationAccountName/$variable"
        # Select-Object, not ForEach-Object Name: the member form of ForEach-Object supports
        # ShouldProcess, so under -WhatIf it produces its own confirmation line and returns nothing,
        # and the message that is meant to say which schedules are involved says none of them.
        $names = @($stored | Select-Object -ExpandProperty Name) -join ', '
        $what = "Store $($stored.Count) schedule(s): $names" +
        $(if ($replaced) { " (replacing $($replaced -join ', '))" } else { '' })

        if (-not $PSCmdlet.ShouldProcess($target, $what)) { return }

        $toStore = [System.Collections.Generic.List[object]]::new()
        foreach ($key in @($existing.Keys)) { $toStore.Add($existing[$key]) }
        Set-VmPowerCatalogVariable @common -VariableName $variable -Catalog $toStore.ToArray()

        if ($PassThru) { $stored }
    }
}