Modules/businessdev.ALbuild.Containers/Public/Optimize-BcImageCache.ps1

function Optimize-BcImageCache {
    <#
    .SYNOPSIS
        Trims the cached Business Central images to a size budget, least-recently-used first.
 
    .DESCRIPTION
        The version-specific image cache is the only thing that removes the per-container install cost,
        and it is also the largest thing on the agent's disk. It therefore needs a retention policy of
        its own rather than being swept up by the generic cache prune - Clear-ALbuildCache deliberately
        excludes 'albuild.image' images, because it runs under disk pressure and would delete the cache
        at exactly the moment it is most valuable.
 
        Eviction is by least-recent USE, not by age. An image is built once and reused for months, so
        evicting the oldest would discard the platform versions being asked for most.
 
        Two things are never evicted:
          * an image a container still references - removing it would break a running build;
          * an image in -Pin, which the caller is about to use. A run that pins the versions in its plan
            cannot have the cache pulled out from under it by its own trimming step.
 
        Note the budget is measured against each image's total reported size, which includes the layers
        shared with the generic base. That over-counts: the base is stored once for all of them. Being
        conservative here is intentional - overshooting the disk is far more expensive than keeping one
        image fewer.
 
    .PARAMETER BudgetGb
        Total size to keep. 0 disables the size check (then only -MinFreeDiskGb applies).
 
    .PARAMETER MinFreeDiskGb
        Keep evicting while free disk is below this. 0 disables the free-space check.
 
    .PARAMETER Pin
        Image names that must never be evicted.
 
    .PARAMETER DockerExecutable
        Docker executable.
 
    .OUTPUTS
        PSCustomObject: Removed[], KeptCount, FreedBytes.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType([PSCustomObject])]
    param(
        [ValidateRange(0, [int]::MaxValue)] [int] $BudgetGb = 400,
        [ValidateRange(0, [int]::MaxValue)] [int] $MinFreeDiskGb = 80,
        [string[]] $Pin = @(),
        [string] $DockerExecutable = 'docker'
    )

    $images = @(Get-BcImage -DockerExecutable $DockerExecutable)
    if ($images.Count -eq 0) {
        Write-ALbuildLog 'No cached Business Central images; nothing to trim.'
        return [PSCustomObject]@{ Removed = @(); KeptCount = 0; FreedBytes = [long]0 }
    }

    $pinned = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($p in $Pin) { if (-not [string]::IsNullOrWhiteSpace($p)) { [void]$pinned.Add($p.Trim()) } }

    # Least recently used first - that is the eviction order.
    $candidates = @($images |
            Where-Object { -not $_.InUse -and -not $pinned.Contains($_.ImageName) } |
            Sort-Object LastUsedUtc)

    $totalBytes = [long](@($images | Measure-Object -Property SizeBytes -Sum).Sum)
    $budgetBytes = [long]$BudgetGb * 1GB
    $removed = [System.Collections.Generic.List[object]]::new()
    $freed = [long]0

    $needsTrim = {
        if ($BudgetGb -gt 0 -and ($totalBytes - $freed) -gt $budgetBytes) { return $true }
        if ($MinFreeDiskGb -gt 0) {
            $disk = Get-BcHostFreeDiskGb -DockerExecutable $DockerExecutable
            if ($disk -and $disk.FreeGb -lt $MinFreeDiskGb) { return $true }
        }
        return $false
    }

    foreach ($image in $candidates) {
        if (-not (& $needsTrim)) { break }
        if ($PSCmdlet.ShouldProcess($image.ImageName, "Remove cached BC image ($([Math]::Round($image.SizeBytes / 1GB, 2)) GB, last used $($image.LastUsedUtc))")) {
            try {
                Remove-BcImage -ImageName $image.ImageName -DockerExecutable $DockerExecutable -Confirm:$false | Out-Null
                $freed += $image.SizeBytes
                $removed.Add($image.ImageName)
                Write-ALbuildLog " Evicted '$($image.ImageName)' (last used $($image.LastUsedUtc), $([Math]::Round($image.SizeBytes / 1GB, 2)) GB)."
            }
            catch { Write-ALbuildLog -Level Warning " Could not remove '$($image.ImageName)': $($_.Exception.Message)" }
        }
        else { $freed += $image.SizeBytes }   # -WhatIf: report what would be freed
    }

    if ($removed.Count -eq 0) { Write-ALbuildLog "Image cache within budget ($([Math]::Round($totalBytes / 1GB, 2)) GB across $($images.Count) image(s)); nothing evicted." }
    else { Write-ALbuildLog -Level Success "Evicted $($removed.Count) image(s), reclaiming $([Math]::Round($freed / 1GB, 2)) GB." }

    return [PSCustomObject]@{
        Removed    = $removed.ToArray()
        KeptCount  = $images.Count - $removed.Count
        FreedBytes = $freed
    }
}