Private/Get-AzLocalSideloadState.ps1

function New-AzLocalSideloadState {
    <#
    .SYNOPSIS
        Builds a fresh sideload state object for a cluster.
 
    .DESCRIPTION
        Private helper for the v0.8.7 on-prem sideloading automation. Produces the
        canonical state record persisted (one JSON per cluster) under the SHARED
        UNC state root so that ANY runner/agent can read it without cross-agent
        remoting. The detached copy Scheduled Task and each re-entrant pipeline run
        read/update this record to advance the per-cluster state machine.
 
    .OUTPUTS
        [PSCustomObject] the state record.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)][string]$ClusterName,
        [Parameter(Mandatory = $true)][string]$Version,
        [string]$TaskName = '',
        [string]$MediaPath = '',
        [string]$TargetPath = '',
        [string]$LogPath = '',
        [ValidateSet('Queued', 'Copying', 'Copied', 'Verified', 'Imported', 'Failed', 'Stale')]
        [string]$State = 'Queued'
    )

    $nowUtc = [DateTime]::UtcNow.ToString('o')
    return [PSCustomObject]@{
        ClusterName       = $ClusterName
        Version           = $Version
        State             = $State
        OwningMachine     = $env:COMPUTERNAME
        TaskName          = $TaskName
        MediaPath         = $MediaPath
        TargetPath        = $TargetPath
        LogPath           = $LogPath
        StartUtc          = $nowUtc
        LastHeartbeatUtc  = $nowUtc
        TotalBytes        = [long]0
        CopiedBytes       = [long]0
        Mbps              = [double]0
        EtaUtc            = ''
        ExitCode          = $null
        Retries           = [int]0
        Message           = ''
    }
}

function Get-AzLocalSideloadStatePath {
    <#
    .SYNOPSIS
        Returns the shared-state JSON path for a cluster, creating the state
        directory if needed.
    .OUTPUTS
        [string]
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)][string]$StateRoot,
        [Parameter(Mandatory = $true)][string]$ClusterName
    )

    $stateDir = Join-Path -Path $StateRoot -ChildPath 'state'
    if (-not (Test-Path -LiteralPath $stateDir)) {
        New-Item -ItemType Directory -Path $stateDir -Force | Out-Null
    }
    # Sanitize cluster name for use as a file name.
    $safe = ($ClusterName -replace '[^A-Za-z0-9._-]', '_')
    return (Join-Path -Path $stateDir -ChildPath ("{0}.json" -f $safe))
}

function Get-AzLocalSideloadState {
    <#
    .SYNOPSIS
        Reads the shared sideload state record for a cluster; returns $null when
        no state exists yet.
    .OUTPUTS
        [PSCustomObject] or $null
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)][string]$StateRoot,
        [Parameter(Mandatory = $true)][string]$ClusterName
    )

    $path = Get-AzLocalSideloadStatePath -StateRoot $StateRoot -ClusterName $ClusterName
    if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
        return $null
    }
    try {
        $raw = Get-Content -LiteralPath $path -Raw -ErrorAction Stop
        if ([string]::IsNullOrWhiteSpace($raw)) { return $null }
        return ($raw | ConvertFrom-Json -ErrorAction Stop)
    }
    catch {
        throw "Failed to read sideload state for cluster '$ClusterName' from '$path': $($_.Exception.Message)"
    }
}

function Set-AzLocalSideloadState {
    <#
    .SYNOPSIS
        Persists a sideload state record to the shared UNC state directory using an
        atomic temp-write + move so concurrent readers never see a partial file.
    .OUTPUTS
        [void]
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([void])]
    param(
        [Parameter(Mandatory = $true)][string]$StateRoot,
        [Parameter(Mandatory = $true)][ValidateNotNull()][PSCustomObject]$State
    )

    if ([string]::IsNullOrWhiteSpace([string]$State.ClusterName)) {
        throw "Set-AzLocalSideloadState: State object has no ClusterName."
    }

    $path = Get-AzLocalSideloadStatePath -StateRoot $StateRoot -ClusterName $State.ClusterName
    if (-not $PSCmdlet.ShouldProcess($path, 'Write sideload state')) { return }

    $json = $State | ConvertTo-Json -Depth 6
    $temp = "{0}.{1}.partial" -f $path, ([guid]::NewGuid().ToString('N'))
    try {
        Write-Utf8NoBomFile -Path $temp -Content $json
        Move-Item -LiteralPath $temp -Destination $path -Force
    }
    finally {
        if (Test-Path -LiteralPath $temp -PathType Leaf) {
            Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue
        }
    }
}

function Test-AzLocalSideloadHeartbeatStale {
    <#
    .SYNOPSIS
        Returns $true when a Copying state's heartbeat is older than the stale
        threshold (the owning runner/agent likely died), so the next pipeline run
        can re-drive the copy on a live host.
    .OUTPUTS
        [bool]
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory = $true)][ValidateNotNull()][PSCustomObject]$State,
        [Parameter(Mandatory = $true)][int]$StaleMinutes
    )

    if ($State.State -ne 'Copying') { return $false }
    [DateTime]$last = [DateTime]::MinValue
    if (-not [DateTime]::TryParse([string]$State.LastHeartbeatUtc, [ref]$last)) {
        return $true
    }
    $ageMinutes = ([DateTime]::UtcNow - $last.ToUniversalTime()).TotalMinutes
    return ($ageMinutes -gt $StaleMinutes)
}