Modules/businessdev.ALbuild.Containers/Public/New-BcImage.ps1

function New-BcImage {
    <#
    .SYNOPSIS
        Builds a version-specific Business Central image with the artifact already installed.
 
    .DESCRIPTION
        A container started from the generic image installs the Business Central artifact into its
        writable layer on every start. Measured on build 27197 that install is the dominant cost of a
        runtime-package run - 178 s on BC18 rising to 468 s on BC27, against ~80 s of actual work per
        product. Baking the install into an image layer moves it out of the per-container path: the
        install is paid once per platform version and every later container only starts the service tier.
 
        The build uses the generic image's own install-only entry point, 'start.ps1 -installOnly', which
        performs the installation and stops short of creating the instance, generating certificates and
        starting the service tier. That split matters: 'docker commit' of a fully started container would
        bake in instance state, self-signed certificates and the container hostname, all of which must be
        produced fresh at run time.
 
        WHY IT IS WORTH BUILDING AT ALL
        The image only pays for itself on a repeat visit to the same platform version - building it costs
        an install, same as starting a container once. It wins because platform versions are revisited
        constantly: every product release walks the whole matrix again, and the weekly sweep revisits the
        newest versions for every product. It is therefore a cache with a budget, trimmed by
        Optimize-BcImageCache, not something to build for all ~200 versions.
 
        Concurrency: two workers asking for the same image must not both build it. The build is
        serialised on the same global lock used for the artifact cache, and the presence check is
        repeated under that lock.
 
        Note the BUILD downloads the artifact itself. 'docker build' on Windows cannot bind-mount, so the
        host's artifact cache is not visible to the build container - unlike a normal container start,
        which mounts it at C:\dl. That is a one-off cost per platform version, and it is why the image is
        built once and then reused rather than rebuilt.
 
    .PARAMETER ArtifactUrl
        The artifact to install into the image.
 
    .PARAMETER ImageName
        Image tag. Defaults to Get-BcImageName.
 
    .PARAMETER BaseImage
        The generic image to build on.
 
    .PARAMETER MemoryLimit
        Memory for the BUILD container. Not cosmetic: 'docker build' gives a Windows build container
        1 GB by default and Business Central's start script refuses to install below 3 GB, so without
        this the build fails with "At least 3Gb memory needs to be available to the Container".
 
    .PARAMETER Force
        Rebuild even when the image already exists.
 
    .PARAMETER MinFreeDiskGb
        Refuse to build below this much free disk. An image build that fills the drive mid-layer leaves
        a large dangling layer behind and makes the next build worse.
 
    .PARAMETER TimeoutSeconds
        Build timeout.
 
    .PARAMETER DockerExecutable
        Docker executable.
 
    .EXAMPLE
        New-BcImage -ArtifactUrl (Find-BcArtifactUrl -Type OnPrem -Country de -Version '26.3' -Select Closest)
 
    .OUTPUTS
        PSCustomObject: ImageName, ArtifactUrl, Built, BuildSeconds, SizeBytes.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ArtifactUrl,
        [string] $ImageName,
        [string] $BaseImage = 'mcr.microsoft.com/businesscentral:ltsc2022',
        [string] $MemoryLimit = '8G',
        [switch] $Force,
        [ValidateRange(0, [int]::MaxValue)] [int] $MinFreeDiskGb = 60,
        [ValidateRange(1, [int]::MaxValue)] [int] $TimeoutSeconds = 3600,
        [string] $DockerExecutable = 'docker'
    )

    Test-BcPlatform -Require
    Test-BcDocker -Require -DockerExecutable $DockerExecutable

    if (-not $ImageName) { $ImageName = Get-BcImageName -ArtifactUrl $ArtifactUrl -BaseImage $BaseImage }

    $exists = {
        (Invoke-BcDocker -DockerExecutable $DockerExecutable -Quiet -PassThru -SuccessExitCodes @(0, 1) `
                -Arguments @('image', 'inspect', '--format', '{{.Id}}', $ImageName)).ExitCode -eq 0
    }
    if ((& $exists) -and -not $Force) {
        Write-ALbuildLog "Image '$ImageName' already exists; reusing it."
        return [PSCustomObject]@{ ImageName = $ImageName; ArtifactUrl = $ArtifactUrl; Built = $false; BuildSeconds = 0; SizeBytes = (Get-BcImageSizeBytes -ImageName $ImageName -DockerExecutable $DockerExecutable) }
    }

    if (-not $PSCmdlet.ShouldProcess($ImageName, "Build a Business Central image for $ArtifactUrl")) {
        return [PSCustomObject]@{ ImageName = $ImageName; ArtifactUrl = $ArtifactUrl; Built = $false; BuildSeconds = 0; SizeBytes = 0 }
    }

    # Serialise with the artifact cache lock family so two workers cannot build the same image twice.
    $mutex = New-Object System.Threading.Mutex($false, (Get-ALbuildCacheLockName -Path "bcimage:$ImageName"))
    $held = $false
    $started = Get-Date
    try {
        try { $held = $mutex.WaitOne([TimeSpan]::FromSeconds($TimeoutSeconds)) }
        catch [System.Threading.AbandonedMutexException] { $held = $true }   # prior holder crashed; we own it

        # Re-check under the lock: another worker may have built it while we waited.
        if ((& $exists) -and -not $Force) {
            Write-ALbuildLog "Image '$ImageName' was built by a concurrent build; reusing it."
            return [PSCustomObject]@{ ImageName = $ImageName; ArtifactUrl = $ArtifactUrl; Built = $false; BuildSeconds = 0; SizeBytes = (Get-BcImageSizeBytes -ImageName $ImageName -DockerExecutable $DockerExecutable) }
        }

        if ($MinFreeDiskGb -gt 0) {
            $disk = Get-BcHostFreeDiskGb -DockerExecutable $DockerExecutable
            if ($disk -and $disk.FreeGb -lt $MinFreeDiskGb) {
                throw ("Only $($disk.FreeGb) GB free on $($disk.Drive); building '$ImageName' needs at least $MinFreeDiskGb GB. " +
                    'Run Optimize-BcImageCache to trim the image cache, or lower -MinFreeDiskGb if you know the drive can take it.')
            }
        }

        $context = Join-Path ([System.IO.Path]::GetTempPath()) ('albuild-img-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
        New-Item -ItemType Directory -Force -Path $context | Out-Null
        try {
            # Labels make the cache self-describing and, crucially, prune-protectable: Clear-ALbuildCache
            # excludes 'albuild.image' so the emergency prune cannot delete this cache.
            $dockerfile = @"
FROM $BaseImage
ENV artifactUrl=$ArtifactUrl
ENV accept_eula=Y
ENV accept_outdated=Y
RUN \Run\start.ps1 -installOnly
LABEL albuild.image=bc
LABEL albuild.artifacturl=$ArtifactUrl
LABEL albuild.basetag=$BaseImage
LABEL albuild.created=$((Get-Date).ToString('o'))
"@

            Set-Content -LiteralPath (Join-Path $context 'Dockerfile') -Value $dockerfile -Encoding ASCII

            Write-ALbuildLog "Building image '$ImageName' from $ArtifactUrl (installs Business Central once so later containers only start the service tier)..."
            Invoke-BcDocker -DockerExecutable $DockerExecutable -StreamOutput `
                -Arguments @('build', '--memory', $MemoryLimit, '-t', $ImageName, $context) | Out-Null
        }
        finally {
            Remove-Item -LiteralPath $context -Recurse -Force -ErrorAction SilentlyContinue
        }

        $seconds = [Math]::Round(((Get-Date) - $started).TotalSeconds, 1)
        $size = Get-BcImageSizeBytes -ImageName $ImageName -DockerExecutable $DockerExecutable
        Write-ALbuildLog -Level Success "Image '$ImageName' built in $seconds s ($([Math]::Round($size / 1GB, 2)) GB total)."
        return [PSCustomObject]@{ ImageName = $ImageName; ArtifactUrl = $ArtifactUrl; Built = $true; BuildSeconds = $seconds; SizeBytes = $size }
    }
    finally {
        if ($held) { $mutex.ReleaseMutex() }
        $mutex.Dispose()
    }
}