Private/Assert-HDTSequenceDocument.ps1

function Assert-HDTSequenceDocument {
    <#
        .SYNOPSIS
            Validates a parsed sequence.yaml against the authoring
            rules.
 
        .DESCRIPTION
            The engine's own validator, and the one that actually runs in WinPE:
            Test-Json does not exist under Windows PowerShell 5.1, so
            schemas/sequence.schema.json is a gate for the console, editors and
            CI while this is the gate for a deployment. It mirrors
            Assert-HDTRuleDocument's shape exactly - throw on the first
            violation, return nothing otherwise, every failure a terminating
            HDTConfigurationError naming the file and the offending node.
 
            The authoring rules, in the order they are checked:
 
              document not empty; a mapping; only schemaVersion, id, name,
                        description, variables and steps; schemaVersion present,
                        an integer and not newer than this engine; id matching
                        ^[A-Za-z0-9][A-Za-z0-9_-]*$; a non-empty name; steps
                        present, a list and not empty
              variables every name matching ^HDT[A-Za-z0-9_]*$ and no _HDT* name,
                        which is engine-owned and cannot be assigned
              group a mapping declaring steps; a non-empty group name; no key
                        outside group/condition/runIn/steps; a steps list, WHICH
                        MAY BE EMPTY; a parseable condition; a runIn in the set
              step a mapping declaring type; a non-empty name; a type
                        matching ^[A-Za-z][A-Za-z0-9]*$; boolean continueOnError
                        and resumable; a positive timeoutMinutes; a runIn in the
                        set; a retry mapping with count 0-10, delaySeconds >= 0
                        and a known backoff; a parseable condition
 
            A NODE IS A GROUP WHEN IT DECLARES steps, not when it declares group.
            The reference ApplyDrivers step carries `group: "%HDTDriverGroup%"`
            as a type-specific property, so keying off `group` would reject the
            document the design prints. A node declaring BOTH steps and type is
            the error, and it is the one case JSON Schema draft-07 cannot express
            (see tests/contract/SequenceSchema.Contract.Tests.ps1).
 
            AN EMPTY GROUP IS LEGAL, AND THE DOCUMENT'S OWN EMPTY steps IS NOT.
            A group an administrator has named but not yet filled is a shelf with
            nothing on it: it contributes no step, and the numbering runs
            straight through it. It is what the editor's New Group button
            creates, and what taking the last step out of a group leaves behind.
            A document whose steps list is empty is a different thing - a
            deployment that does nothing at all - and stays refused.
 
            BOTH SPELLINGS OF EMPTY ARRIVE HERE, AND THEY ARE DIFFERENT OBJECTS.
            `steps: []` parses to an empty list; `steps:` with nothing under it
            parses to NULL, no collection at all. A steps key holding anything
            else is still refused.
 
            CONDITIONS ARE PARSED HERE. ConvertFrom-HDTStepCondition runs over
            every step and group condition at import, so a malformed one fails
            authoring rather than a deployment at 3 a.m.
 
            STEP TYPES ARE DELIBERATELY NOT VALIDATED. Types are pluggable and
            discovered at runtime, so a sequence authored for a
            workspace whose Modules\ carries a third-party step must still import
            on a machine that does not have it. An unknown type fails the STEP,
            at execution, naming the types that are known.
 
        .PARAMETER Document
            The parsed document, as returned by ConvertFrom-HDTYaml. $null is
            accepted and reported as an empty file rather than crashing.
 
        .PARAMETER Path
            The file the document came from. Used for the message and the
            TargetObject; this function reads nothing.
 
        .OUTPUTS
            None. It throws or it returns nothing.
 
        .EXAMPLE
            Assert-HDTSequenceDocument -Document (ConvertFrom-HDTYaml -Yaml $text -Path $path) -Path $path
 
        .NOTES
            The locator in a message is the STEP or the GROUP, never a line
            number: the YAML parser does not carry line information onto the
            object graph it returns, so after parsing there is no honest line to
            report. Only ConvertFrom-HDTYaml, which still holds the parser's own
            exception, can name a line.
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [AllowNull()]
        [object] $Document,

        [Parameter(Mandatory = $true, Position = 1)]
        [ValidateNotNullOrEmpty()]
        [string] $Path
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $supportedSchemaVersion = 1
    $allowedRootKey = @('schemaVersion', 'id', 'name', 'description', 'variables', 'steps')
    $allowedGroupKey = @('group', 'condition', 'disabled', 'runIn', 'steps')
    $allowedRetryKey = @('count', 'delaySeconds', 'backoff')
    $allowedRunIn = @('WinPE', 'FullOS', 'Any')
    $allowedBackoff = @('fixed', 'exponential')

    # -- the document ---------------------------------------------------------

    if ($null -eq $Document) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'the file is empty. A sequence document must declare schemaVersion, id, name and at least one step.'))
    }

    if (-not ($Document -is [System.Collections.IDictionary])) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message ("the document must be a mapping with schemaVersion, id, name and steps keys, but it is a {0}." -f $Document.GetType().Name)))
    }

    foreach ($key in @($Document.Keys)) {
        if ($allowedRootKey -notcontains [string] $key) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("'{0}' is not a key a sequence document may declare. The allowed keys are {1}." -f $key, ($allowedRootKey -join ', '))))
        }
    }

    if (-not $Document.Contains('schemaVersion')) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'schemaVersion is missing. Every HDT document declares one; this engine understands schemaVersion 1.'))
    }

    $schemaVersion = $Document['schemaVersion']
    if (-not (($schemaVersion -is [int]) -or ($schemaVersion -is [long]))) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message ("schemaVersion must be an integer, but it is '{0}'." -f $schemaVersion)))
    }

    $supported = $false
    try {
        $supported = Test-HDTSchemaVersion -SchemaVersion ([int] $schemaVersion) -Supported $supportedSchemaVersion
    } catch {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message ("schemaVersion {0} is not a valid schema version. It must be 1 or greater." -f $schemaVersion)))
    }

    if (-not $supported) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message ("schemaVersion {0} is newer than this engine understands (schemaVersion {1}). Upgrade the engine rather than the workspace." -f $schemaVersion, $supportedSchemaVersion)))
    }

    if (-not $Document.Contains('id')) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'the id key is missing. The id is what state.json records, so a run cannot be attributed without it.'))
    }

    $id = [string] $Document['id']
    if ($id -cnotmatch '^[A-Za-z0-9][A-Za-z0-9_-]*$') {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message ("the id '{0}' is not usable. An id starts with a letter or a digit and continues with letters, digits, hyphens and underscores." -f $id)))
    }

    if (-not $Document.Contains('name')) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'the name key is missing. The name is what a technician chooses this sequence by.'))
    }

    if ([string]::IsNullOrWhiteSpace([string] $Document['name'])) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'the name is empty. The name is what a technician chooses this sequence by.'))
    }

    # -- variables ------------------------------------------------------------

    if ($Document.Contains('variables')) {
        $variable = $Document['variables']

        if (-not ($variable -is [System.Collections.IDictionary])) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message 'variables must be a mapping of variable name to value.'))
        }

        foreach ($key in @($variable.Keys)) {
            $name = [string] $key

            if ($name.StartsWith('_')) {
                $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                            -Message ("'{0}' is engine-owned and cannot be assigned. A variable named _HDT* is set by the engine and is read-only." -f $name)))
            }

            if ($name -cnotmatch '^HDT[A-Za-z0-9_]*$') {
                $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                            -Message ("'{0}' is not an HDT variable name. Every deployment variable is prefixed HDT; run Get-HDTVariableMap for the MDT translation." -f $name)))
            }
        }
    }

    # -- steps ----------------------------------------------------------------

    if (-not $Document.Contains('steps')) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'the steps key is missing. A sequence document declares a steps list, even if it holds a single step.'))
    }

    $rootStep = $Document['steps']
    if (-not ($rootStep -is [System.Collections.IList])) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'the steps key must be a list of steps and groups.'))
    }

    if (@($rootStep).Count -eq 0) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                    -Message 'the steps list is empty. A sequence document must declare at least one step.'))
    }

    # A depth-first preorder walk with an explicit stack rather than recursion,
    # so every message is raised from this function and carries its own
    # ThrowTerminatingError. Children are pushed in reverse so they pop in
    # document order.
    $stack = New-Object -TypeName System.Collections.Stack
    for ($position = @($rootStep).Count - 1; $position -ge 0; $position--) {
        $stack.Push([pscustomobject] @{ Node = @($rootStep)[$position]; GroupPath = @() })
    }

    $stepIndex = 0

    while ($stack.Count -gt 0) {
        $frame = $stack.Pop()
        $node = $frame.Node
        $groupPath = @($frame.GroupPath)

        $where = 'at the root of the sequence'
        if ($groupPath.Count -gt 0) {
            $where = "in group '{0}'" -f ($groupPath -join '/')
        }

        if (-not ($node -is [System.Collections.IDictionary])) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("a node {0} must be a mapping: either a group with a steps list, or a step with a name and a type." -f $where)))
        }

        $isGroup = $node.Contains('steps')
        $isStep = $node.Contains('type')

        # The label a message names the node by: its group name, or its step
        # name, or nothing when it declared neither.
        $label = ''
        if ($node.Contains('group')) {
            $label = [string] $node['group']
        } elseif ($node.Contains('name')) {
            $label = [string] $node['name']
        }

        if ($isGroup -and $isStep) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("the node '{0}' {1} declares both steps and type, so it is both a group and a step. A group declares group and steps; a step declares name and type." -f $label, $where)))
        }

        if (-not $isGroup -and -not $isStep) {
            if ($node.Contains('group')) {
                $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                            -Message ("group '{0}' declares no steps key. The steps key is what tells a group from a step, so a group declares one even when there is nothing in it yet: write 'steps: []'." -f $label)))
            }

            $stepIndex++
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("step {0} ('{1}'): every step declares a type, which names the Invoke-HDT<Type>Step function that runs it." -f $stepIndex, $label)))
        }

        # -- a group ----------------------------------------------------------

        if ($isGroup) {
            if (-not $node.Contains('group') -or [string]::IsNullOrWhiteSpace([string] $node['group'])) {
                $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                            -Message ("a group {0} declares no name. Every group is named, because the name is what a step.skip record reports." -f $where)))
            }

            $groupName = [string] $node['group']
            $locator = "group '{0}'" -f ((@($groupPath) + $groupName) -join '/')

            foreach ($key in @($node.Keys)) {
                if ($allowedGroupKey -notcontains [string] $key) {
                    $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                                -Message ("{0}: '{1}' is not a key a group may declare. The allowed keys are {2}." -f $locator, $key, ($allowedGroupKey -join ', '))))
                }
            }

            # $null IS THE PARSER'S ANSWER FOR `steps:` WITH NOTHING UNDER IT,
            # and it means the same as `steps: []`. Anything else - a scalar, a
            # mapping - is still a mistake worth naming.
            $child = $node['steps']
            if ($null -ne $child -and -not ($child -is [System.Collections.IList])) {
                $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                            -Message ("{0}: steps must be a list of steps and groups." -f $locator)))
            }

            if ($null -eq $child) {
                $child = @()
            }

            if ($node.Contains('condition')) {
                try {
                    # No -Path here: the locator below already names the file, and
                    # prefixing twice reads like two separate failures.
                    $null = ConvertFrom-HDTStepCondition -Condition ([string] $node['condition'])
                } catch {
                    $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                                -Message ("{0}: {1}" -f $locator, $_.Exception.Message)))
                }
            }

            if ($node.Contains('runIn') -and ($allowedRunIn -notcontains [string] $node['runIn'])) {
                $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                            -Message ("{0}: runIn '{1}' is not a phase. It is one of {2}." -f $locator, $node['runIn'], ($allowedRunIn -join ', '))))
            }

            $childPath = @($groupPath) + $groupName
            for ($position = @($child).Count - 1; $position -ge 0; $position--) {
                $stack.Push([pscustomobject] @{ Node = @($child)[$position]; GroupPath = $childPath })
            }

            continue
        }

        # -- a step -----------------------------------------------------------

        $stepIndex++

        if (-not $node.Contains('name') -or [string]::IsNullOrWhiteSpace([string] $node['name'])) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("step {0} {1}: every step needs a non-empty name. The name is what the log, the progress display and state.json report." -f $stepIndex, $where)))
        }

        $stepName = [string] $node['name']
        $locator = "step {0} ('{1}')" -f $stepIndex, $stepName

        $type = [string] $node['type']
        if ($type -cnotmatch '^[A-Za-z][A-Za-z0-9]*$') {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0}: '{1}' is not a step type. A type is a letter followed by letters and digits, and names the Invoke-HDT<Type>Step function that runs it." -f $locator, $type)))
        }

        foreach ($flag in @('continueOnError', 'disabled', 'resumable')) {
            if ($node.Contains($flag) -and -not ($node[$flag] -is [bool])) {
                $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                            -Message ("{0}: {1} must be true or false, but it is '{2}'." -f $locator, $flag, $node[$flag])))
            }
        }

        if ($node.Contains('timeoutMinutes')) {
            $timeout = $node['timeoutMinutes']
            $isInteger = ($timeout -is [int]) -or ($timeout -is [long])

            # 0 IS HOW A DOCUMENT SAYS "no limit" OUT LOUD, and it is accepted
            # for that. It used to be refused - the ABSENCE of the key meant
            # unbounded and a written 0 was read as a likely mistake - but a
            # properties page with a Time limit box has to be able to express
            # it, and a box left blank says it worse than a number does.
            #
            # THE RUNNER ALREADY BEHAVED THIS WAY: Invoke-HDTStepAttempt arms a
            # timeout only when the value is greater than zero, so this removes
            # a refusal rather than adding a behaviour.
            if (-not $isInteger -or [int] $timeout -lt 0) {
                $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                            -Message ("{0}: timeoutMinutes must be a whole number of minutes and not negative, but it is '{1}'. Use 0, or omit it, for a step that is not time limited." -f $locator, $timeout)))
            }
        }

        if ($node.Contains('runIn') -and ($allowedRunIn -notcontains [string] $node['runIn'])) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0}: runIn '{1}' is not a phase. It is one of {2}." -f $locator, $node['runIn'], ($allowedRunIn -join ', '))))
        }

        if ($node.Contains('log') -and [string]::IsNullOrWhiteSpace([string] $node['log'])) {
            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                        -Message ("{0}: log must be a file name in the log directory." -f $locator)))
        }

        if ($node.Contains('retry')) {
            $retry = $node['retry']

            if (-not ($retry -is [System.Collections.IDictionary])) {
                $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                            -Message ("{0}: retry must be a mapping of count, delaySeconds and backoff." -f $locator)))
            }

            foreach ($key in @($retry.Keys)) {
                if ($allowedRetryKey -notcontains [string] $key) {
                    $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                                -Message ("{0}: '{1}' is not a key retry may declare. The allowed keys are {2}." -f $locator, $key, ($allowedRetryKey -join ', '))))
                }
            }

            if ($retry.Contains('count')) {
                $count = $retry['count']
                $isInteger = ($count -is [int]) -or ($count -is [long])

                if (-not $isInteger -or [int] $count -lt 0 -or [int] $count -gt 10) {
                    $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                                -Message ("{0}: retry count must be a whole number between 0 and 10, but it is '{1}'. A step that needs more than ten attempts is broken, not flaky." -f $locator, $count)))
                }
            }

            if ($retry.Contains('delaySeconds')) {
                $delay = $retry['delaySeconds']
                $isInteger = ($delay -is [int]) -or ($delay -is [long])

                if (-not $isInteger -or [int] $delay -lt 0) {
                    $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                                -Message ("{0}: retry delaySeconds must be zero or more seconds, but it is '{1}'." -f $locator, $delay)))
                }
            }

            if ($retry.Contains('backoff') -and ($allowedBackoff -notcontains [string] $retry['backoff'])) {
                $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                            -Message ("{0}: retry backoff '{1}' is unknown. It is one of {2}." -f $locator, $retry['backoff'], ($allowedBackoff -join ', '))))
            }
        }

        if ($node.Contains('condition')) {
            try {
                # No -Path here: the locator below already names the file, and
                    # prefixing twice reads like two separate failures.
                    $null = ConvertFrom-HDTStepCondition -Condition ([string] $node['condition'])
            } catch {
                $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path `
                            -Message ("{0}: {1}" -f $locator, $_.Exception.Message)))
            }
        }
    }
}