Tools/Invoke-AzLocalSideloadCopyTask.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
    Detached copy worker for AzLocal.UpdateManagement on-prem sideloading (v0.8.7).
 
.DESCRIPTION
    This script is the ACTION of the Windows Scheduled Task registered by
    Register-AzLocalSideloadCopyTask. It is intentionally SELF-CONTAINED (it does
    NOT import the AzLocal.UpdateManagement module) because it runs in a separate
    process, as the runner/agent service account, and must survive the CI/CD job
    ending and even a host reboot.
 
    It robocopies the verified solution media (a CombinedSolutionBundle .zip for a
    Microsoft Solution update, or a staged OEM SBE source folder) from the shared
    cache to the target cluster's infrastructure 'import' share, writing a
    structured heartbeat JSON to the SHARED state directory every few seconds so
    that ANY pipeline run on ANY runner/agent can monitor progress without
    cross-agent remoting.
 
    Robocopy exit codes 0-7 are treated as success; >= 8 is failure.
 
.PARAMETER ClusterName
    The Azure Local cluster name (used to name the shared-state JSON file).
 
.PARAMETER Version
    The solution-update version being copied (recorded in state).
 
.PARAMETER OperationId
    Unique operation identifier. The worker exits when a newer operation owns
    the cluster state file.
 
.PARAMETER SourcePath
    The verified media path: a .zip file (Solution) or a folder (SBE).
 
.PARAMETER TargetPath
    The destination UNC folder on the cluster's import share.
 
.PARAMETER StateRoot
    The SHARED UNC state root. state\<cluster>.json and logs\ live beneath it.
 
.PARAMETER RobocopySwitches
    Extra robocopy switches (e.g. '/R:5 /W:30 /IPG:50'); space-separated.
 
.PARAMETER HeartbeatSeconds
    Interval between heartbeat JSON updates. Default 30.
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)][string]$ClusterName,
    [Parameter(Mandatory = $true)][string]$Version,
    [Parameter(Mandatory = $true)][string]$OperationId,
    [Parameter(Mandatory = $true)][string]$SourcePath,
    [Parameter(Mandatory = $true)][string]$TargetPath,
    [Parameter(Mandatory = $true)][string]$StateRoot,
    [string]$RobocopySwitches = '/R:5 /W:30',
    [int]$HeartbeatSeconds = 30
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$statePath = $null
$state = $null
$logPath = ''

trap {
    $failureMessage = "Copy worker failed: $($_.Exception.Message)"
    try {
        if ($null -eq $statePath) {
            $statePath = Get-StateFilePath -Root $StateRoot -Cluster $ClusterName
        }
        if ($null -eq $state) {
            $nowUtc = [DateTime]::UtcNow.ToString('o')
            $state = [PSCustomObject]@{
                ClusterName = $ClusterName; Version = $Version; OperationId = $OperationId
                State = 'Failed'; OwningMachine = $env:COMPUTERNAME; TaskName = ''
                MediaPath = $SourcePath; TargetPath = $TargetPath; LogPath = $logPath
                StartUtc = $nowUtc; LastHeartbeatUtc = $nowUtc; LastProgressUtc = $nowUtc
                TotalBytes = [long]0; CopiedBytes = [long]0; Mbps = [double]0; EtaUtc = ''
                ExitCode = $null; WorkerProcessId = $PID; RobocopyProcessId = $null
                Retries = [int]0; ImportRetries = [int]0; Message = $failureMessage
            }
        }
        else {
            $state.State = 'Failed'
            $state.LastHeartbeatUtc = [DateTime]::UtcNow.ToString('o')
            $state.Message = $failureMessage
        }
        Write-StateAtomic -Path $statePath -State $state
        if (-not [string]::IsNullOrWhiteSpace($logPath)) {
            Add-Content -LiteralPath $logPath -Value $failureMessage -Encoding UTF8 -ErrorAction SilentlyContinue
        }
    }
    catch {
        Write-Error "$failureMessage State persistence also failed: $($_.Exception.Message)" -ErrorAction Continue
    }
    exit 1
}

function Get-StateFilePath {
    param([string]$Root, [string]$Cluster)
    $stateDir = Join-Path -Path $Root -ChildPath 'state'
    if (-not (Test-Path -LiteralPath $stateDir)) { New-Item -ItemType Directory -Path $stateDir -Force | Out-Null }
    $safe = ($Cluster -replace '[^A-Za-z0-9._-]', '_')
    return (Join-Path -Path $stateDir -ChildPath ("{0}.json" -f $safe))
}

function Write-StateAtomic {
    param([string]$Path, [PSCustomObject]$State)

    if (Test-Path -LiteralPath $Path -PathType Leaf) {
        $current = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
        if ([string]$current.OperationId -ne [string]$State.OperationId) {
            throw "Sideload operation '$($State.OperationId)' no longer owns state '$Path'."
        }
    }
    $json = $State | ConvertTo-Json -Depth 6
    $temp = "{0}.{1}.partial" -f $Path, ([guid]::NewGuid().ToString('N'))
    try {
        [System.IO.File]::WriteAllText($temp, $json, [System.Text.UTF8Encoding]::new($false))
        Move-Item -LiteralPath $temp -Destination $Path -Force
    }
    finally {
        if (Test-Path -LiteralPath $temp -PathType Leaf) { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue }
    }
}

function Get-FolderSize {
    param([string]$Path)
    if (-not (Test-Path -LiteralPath $Path)) { return [long]0 }
    if (Test-Path -LiteralPath $Path -PathType Leaf) {
        return [long]((Get-Item -LiteralPath $Path).Length)
    }
    $sum = (Get-ChildItem -LiteralPath $Path -Recurse -File -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum
    if ($null -eq $sum) { return [long]0 }
    return [long]$sum
}

$statePath = Get-StateFilePath -Root $StateRoot -Cluster $ClusterName
$logDir = Join-Path -Path $StateRoot -ChildPath 'logs'
if (-not (Test-Path -LiteralPath $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
$safeName = ($ClusterName -replace '[^A-Za-z0-9._-]', '_')
$logPath = Join-Path -Path $logDir -ChildPath ("{0}.{1}.robocopy.log" -f $safeName, ([DateTime]::UtcNow.ToString('yyyyMMddHHmmss')))

# Carry forward existing fields (retries, taskname) when present.
$existing = $null
if (Test-Path -LiteralPath $statePath -PathType Leaf) {
    try { $existing = (Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json) } catch { $existing = $null }
}

$startUtc = [DateTime]::UtcNow
$totalBytes = Get-FolderSize -Path $SourcePath

$state = [PSCustomObject]@{
    ClusterName      = $ClusterName
    Version          = $Version
    OperationId      = $OperationId
    State            = 'Copying'
    OwningMachine    = $env:COMPUTERNAME
    TaskName         = if ($existing) { [string]$existing.TaskName } else { '' }
    MediaPath        = $SourcePath
    TargetPath       = $TargetPath
    LogPath          = $logPath
    StartUtc         = $startUtc.ToString('o')
    LastHeartbeatUtc = $startUtc.ToString('o')
    TotalBytes       = $totalBytes
    CopiedBytes      = [long]0
    Mbps             = [double]0
    EtaUtc           = ''
    ExitCode         = $null
    WorkerProcessId  = $PID
    RobocopyProcessId = $null
    LastProgressUtc  = $startUtc.ToString('o')
    Retries          = if ($existing) { [int]$existing.Retries } else { [int]0 }
    ImportRetries    = if ($existing -and $existing.PSObject.Properties['ImportRetries']) { [int]$existing.ImportRetries } else { [int]0 }
    Message          = 'Copy started.'
}
Write-StateAtomic -Path $statePath -State $state

# Ensure destination exists.
if (-not (Test-Path -LiteralPath $TargetPath)) { New-Item -ItemType Directory -Path $TargetPath -Force | Out-Null }

# Build the robocopy argument list. For a single-file source, robocopy copies
# from the parent directory with the file name as a selector; for a folder it
# mirrors the tree.
if (Test-Path -LiteralPath $SourcePath -PathType Leaf) {
    $srcDir = Split-Path -Path $SourcePath -Parent
    $srcFile = Split-Path -Path $SourcePath -Leaf
    $roboArgs = @($srcDir, $TargetPath, $srcFile)
}
else {
    $roboArgs = @($SourcePath, $TargetPath, '/E')
}
$roboArgs += ($RobocopySwitches -split '\s+' | Where-Object { $_ })
$roboArgs += @('/NP', '/NJH', '/NJS', ("/LOG:{0}" -f $logPath))

$proc = Start-Process -FilePath 'robocopy.exe' -ArgumentList $roboArgs -PassThru
$state.RobocopyProcessId = $proc.Id
$state.Message = "Copy started (worker PID $PID, robocopy PID $($proc.Id))."
Write-StateAtomic -Path $statePath -State $state
while (-not $proc.HasExited) {
    Start-Sleep -Seconds $HeartbeatSeconds
    $copied = Get-FolderSize -Path $TargetPath
    $elapsed = ([DateTime]::UtcNow - $startUtc).TotalSeconds
    $mbps = if ($elapsed -gt 0) { [math]::Round((($copied * 8) / 1MB) / $elapsed, 2) } else { 0 }
    $eta = ''
    if ($copied -gt 0 -and $totalBytes -gt $copied -and $elapsed -gt 0) {
        $bytesPerSec = $copied / $elapsed
        if ($bytesPerSec -gt 0) {
            $remainingSec = ($totalBytes - $copied) / $bytesPerSec
            $eta = [DateTime]::UtcNow.AddSeconds($remainingSec).ToString('o')
        }
    }
    if ($copied -gt [long]$state.CopiedBytes) {
        $state.LastProgressUtc = [DateTime]::UtcNow.ToString('o')
    }
    $state.CopiedBytes = [long]$copied
    $state.Mbps = [double]$mbps
    $state.EtaUtc = $eta
    $state.LastHeartbeatUtc = [DateTime]::UtcNow.ToString('o')
    $state.Message = 'Copy in progress.'
    Write-StateAtomic -Path $statePath -State $state
}

$exit = $proc.ExitCode
$state.ExitCode = $exit
$state.CopiedBytes = Get-FolderSize -Path $TargetPath
$state.LastHeartbeatUtc = [DateTime]::UtcNow.ToString('o')
if ($exit -lt 8) {
    $state.State = 'Copied'
    $state.Message = ("Copy completed (robocopy exit {0})." -f $exit)
}
else {
    $state.State = 'Failed'
    $state.Message = ("Copy FAILED (robocopy exit {0}); see log {1}." -f $exit, $logPath)
}
Write-StateAtomic -Path $statePath -State $state

# Surface a non-zero process exit only on real failure (>= 8).
if ($exit -ge 8) { exit 1 } else { exit 0 }