Private/Convert-AzLocalScheduleSchemaVersion.ps1

function Convert-AzLocalScheduleSchemaVersion {
    <#
    .SYNOPSIS
        Migrates an apply-updates-schedule.yml file from an older schema
        version to the module's current schema version (or any chosen
        target). Text-surgery based - customer comments and row order are
        preserved verbatim.
 
    .DESCRIPTION
        The function walks the per-hop recipe table registered in this
        file (see $script:ScheduleSchemaRecipes below). Each recipe is a
        ScriptBlock with signature:
 
            param([string]$Text) -> @{ Text = <new>; Changes = @(<strings>) }
 
        Recipes operate on RAW TEXT, not on the parsed structure. This is
        deliberate so operator-authored YAML comments above schedule
        rows (typically change-control references) survive every
        migration hop. Each recipe is expected to be IDEMPOTENT: running
        it twice on the same input must produce the same output.
 
        Walker behaviour:
          * If $current == $target -> return Migrated=$false (no-op).
          * If $current > $target -> throw (downgrade requested; bad).
          * If $current < $target -> walk recipes $current -> $current+1
                                       -> ... -> $target. If any hop is
                                       missing from the table, throw.
 
        Backup-on-write is performed by the caller
        (Update-AzLocalPipelineExample) - this function only computes
        the new text and reports what changed.
 
    .PARAMETER Text
        Raw YAML text of the customer's schedule file.
 
    .PARAMETER TargetSchemaVersion
        Schema version to migrate TO. Default: this module's current
        ($script:ScheduleSchemaCurrentVersion). Tests can override to
        exercise specific hops.
 
    .PARAMETER SourcePath
        Optional path used in error messages.
 
    .OUTPUTS
        [PSCustomObject] with:
          Migrated [bool]
          FromVersion [int]
          ToVersion [int]
          NewText [string] - migrated YAML text (or original if no-op)
          Hops [object[]] - one row per executed recipe:
                                       { FromVersion, ToVersion, Changes[] }
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Text,

        [Parameter(Mandatory = $false)]
        [int]$TargetSchemaVersion = $script:ScheduleSchemaCurrentVersion,

        [Parameter(Mandatory = $false)]
        [string]$SourcePath = '<inline>'
    )

    # Parse just enough to read the current schemaVersion. Re-parse after
    # each hop so future recipes can rely on structured access if they want.
    $cfg = ConvertFrom-AzLocalScheduleYaml -Text $Text -SourcePath $SourcePath
    if ($null -eq $cfg.SchemaVersion -or $cfg.SchemaVersion -isnot [int]) {
        throw "Convert-AzLocalScheduleSchemaVersion: '$SourcePath' has no readable top-level 'schemaVersion'. Cannot migrate."
    }
    $current = [int]$cfg.SchemaVersion

    if ($current -gt $TargetSchemaVersion) {
        throw "Convert-AzLocalScheduleSchemaVersion: '$SourcePath' is on schemaVersion=$current but this module only supports up to $TargetSchemaVersion. Upgrade the AzLocal.UpdateManagement module, then re-run Update-AzLocalPipelineExample."
    }

    if ($current -eq $TargetSchemaVersion) {
        return [pscustomobject]@{
            Migrated    = $false
            FromVersion = $current
            ToVersion   = $TargetSchemaVersion
            NewText     = $Text
            Hops        = @()
        }
    }

    $hops = New-Object System.Collections.Generic.List[object]
    $workingText = $Text
    for ($v = $current; $v -lt $TargetSchemaVersion; $v++) {
        $key = "$v->$($v + 1)"
        # $script:ScheduleSchemaRecipes is an [ordered] dictionary, which
        # exposes .Contains() but NOT .ContainsKey() - the latter throws
        # MethodNotFound on System.Collections.Specialized.OrderedDictionary.
        if (-not $script:ScheduleSchemaRecipes.Contains($key)) {
            throw "Convert-AzLocalScheduleSchemaVersion: no migration recipe registered for '$key'. The module is missing a hop - this is a bug; file at https://github.com/NeilBird/Azure-Local/issues."
        }
        $recipe = $script:ScheduleSchemaRecipes[$key]
        $hopResult = & $recipe $workingText
        if (-not $hopResult.ContainsKey('Text') -or -not $hopResult.ContainsKey('Changes')) {
            throw "Convert-AzLocalScheduleSchemaVersion: recipe '$key' did not return the expected @{ Text=...; Changes=... } shape."
        }
        $workingText = [string]$hopResult.Text
        $hops.Add([pscustomobject]@{
            FromVersion = $v
            ToVersion   = $v + 1
            Changes     = @($hopResult.Changes)
        }) | Out-Null
    }

    return [pscustomobject]@{
        Migrated    = $true
        FromVersion = $current
        ToVersion   = $TargetSchemaVersion
        NewText     = $workingText
        Hops        = $hops.ToArray()
    }
}

# =====================================================================
# Schedule schema migration recipes - dispatch table
# =====================================================================
# Each value is a ScriptBlock with signature:
# param([string]$Text) -> @{ Text = <new YAML>; Changes = @(<strings>) }
#
# Recipes MUST be idempotent (running twice = same output as running once)
# and MUST update the top-level 'schemaVersion:' line themselves.
#
# To add a new hop in a future module version:
# 1. Bump $script:ScheduleSchemaCurrentVersion in the .psm1.
# 2. Append a recipe here with the new 'N->N+1' key.
# 3. Add tests in Tests/AzLocal.UpdateManagement.Tests.ps1 for the new
# hop in isolation AND chained from version 1.
# =====================================================================
$script:ScheduleSchemaRecipes = [ordered]@{
    # =====================================================================
    # 1 -> 2 (shipped in v0.7.89)
    # =====================================================================
    # v2 makes the top-level 'allowedUpdateVersions' field MANDATORY and
    # adds the optional per-row override. The migration is ADDITIVE -
    # existing schedule rows are not modified:
    # * The 'schemaVersion: 1' line is rewritten to 'schemaVersion: 2'
    # in place (preserving leading whitespace and any trailing
    # comment).
    # * A documented top-level block is inserted just above the
    # '# ---- Schedule entries ----' header (or, if missing, just
    # before the 'schedule:' key). The block contains:
    # (a) explanatory comments describing the new field; and
    # (b) the active line allowedUpdateVersions: 'Latest'
    # 'Latest' is the reserved sentinel meaning "no constraint -
    # install the latest Ready update on each cluster" (the
    # historic v0.7.88 behaviour), so the migrated file behaves
    # identically to the v1 source out of the box. Operators
    # replace 'Latest' with a semicolon-separated list of explicit
    # update names / version strings to enforce a "minimum updates"
    # allow-list policy fleet-wide.
    # The recipe is idempotent:
    # * 'schemaVersion: 2' is left alone if it is already there.
    # * The block is keyed off the literal marker
    # '# >>> ALLOWED-UPDATE-VERSIONS-V2 <<<'. If that marker is
    # present anywhere in the file, the block is not re-inserted.
    # Per-row allowedUpdateVersions opt-in is the operator's choice on
    # their own schedule rows; the migration does not touch row content.
    '1->2' = {
        param([string]$Text)
        $changes = New-Object System.Collections.Generic.List[string]
        $work    = $Text

        # 1. Rewrite schemaVersion line.
        $svPattern = '(?m)^(\s*)schemaVersion(\s*:\s*)1(\s*(?:#.*)?)$'
        $svRegex   = [regex]::new($svPattern)
        $svMatch   = $svRegex.Match($work)
        if ($svMatch.Success) {
            $work = $svRegex.Replace($work, { param($m)
                "$($m.Groups[1].Value)schemaVersion$($m.Groups[2].Value)2$($m.Groups[3].Value)"
            }, 1)
            $changes.Add("Rewrote 'schemaVersion: 1' to 'schemaVersion: 2'.") | Out-Null
        }

        # 2. Insert the mandatory top-level block (idempotent via marker).
        $marker = '# >>> ALLOWED-UPDATE-VERSIONS-V2 <<<'
        if ($work -notmatch [regex]::Escape($marker)) {
            $block = @(
                '',
                "# ---- AllowedUpdateVersions (schema v2, MANDATORY top-level) -------",
                "# $marker",
                "# Fleet-wide allow-list of Azure Local solution-update names or",
                "# version strings that Step.6 (apply-updates) is permitted to install.",
                "#",
                "# Default 'Latest' (case-insensitive) is a reserved sentinel meaning",
                "# 'no constraint - install the latest Ready update on each cluster'",
                "# (the historic v0.7.88 default). Leave it as 'Latest' to keep your",
                "# v1 behaviour unchanged.",
                "#",
                "# To enforce a 'minimum updates' policy (~4 updates per year - YY04 +",
                "# YY10 feature updates plus the preceding YY03 + YY09 cumulative",
                "# updates), replace 'Latest' with a semicolon-separated list of",
                "# explicit update names or version strings. Clusters with no Ready",
                "# update matching the list are SKIPPED with status 'NotInAllowList'",
                "# (strict no-op; never falls back to 'latest').",
                "#",
                "# Example (uncomment + edit, replacing the 'Latest' line below):",
                "# allowedUpdateVersions: '10.2604.0.123;10.2610.0.456'",
                "#",
                "# Per-row override: any schedule row below may set its own",
                "# 'allowedUpdateVersions:' field. Per-row beats top-level; multiple",
                "# matching rows UNION their lists; rows without the field on a",
                "# UNION day are treated as 'no opinion' (not 'allow nothing'); if",
                "# any matching row contributes 'Latest', the effective list is",
                "# 'Latest' (no constraint).",
                "allowedUpdateVersions: 'Latest'",
                ''
            ) -join "`r`n"

            # Prefer to insert right before the '# ---- Schedule entries' banner.
            # Fall back to inserting just before the bare 'schedule:' key.
            $headerRx = [regex]::new('(?m)^(?<spc>[ \t]*)# ---- Schedule entries[^\r\n]*[\r\n]+')
            $schedRx  = [regex]::new('(?m)^(?<spc>[ \t]*)schedule\s*:')
            $headerM  = $headerRx.Match($work)
            if ($headerM.Success) {
                $work = $work.Insert($headerM.Index, $block)
                $changes.Add("Inserted mandatory top-level 'allowedUpdateVersions: ''Latest''' block above '# ---- Schedule entries ----'.") | Out-Null
            }
            else {
                $schedM = $schedRx.Match($work)
                if ($schedM.Success) {
                    $work = $work.Insert($schedM.Index, $block)
                    $changes.Add("Inserted mandatory top-level 'allowedUpdateVersions: ''Latest''' block above 'schedule:'.") | Out-Null
                }
                else {
                    # No anchor found - append at end. Should be rare; the
                    # validator already requires a 'schedule:' key.
                    $work = $work.TrimEnd("`r","`n") + "`r`n" + $block + "`r`n"
                    $changes.Add("Appended mandatory top-level 'allowedUpdateVersions: ''Latest''' block at end (no 'schedule:' anchor found).") | Out-Null
                }
            }
        }

        return @{
            Text    = $work
            Changes = $changes.ToArray()
        }
    }
}