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

function New-BcContainer {
    <#
    .SYNOPSIS
        Creates a Business Central Docker container from an artifact.
 
    .DESCRIPTION
        Pulls the Business Central generic image (if needed) and runs a container configured for
        the given artifact URL using the generic image's environment contract (accept_eula,
        artifactUrl, Auth, username, password, licenseFile). Requires a Windows host with a running
        Docker engine; on other platforms it fails with a clear message. After starting, it waits
        for the container to report ready unless -NoWait is specified.
 
    .PARAMETER Name
        Container name.
 
    .PARAMETER ArtifactUrl
        The artifact URL (see Find-BcArtifactUrl). Required unless -Type/-Country/-Version are used.
 
    .PARAMETER Credential
        Credential for the container's admin user (used with UserPassword auth).
 
    .PARAMETER Auth
        Authentication model: UserPassword (default), NavUserPassword, Windows or AAD.
 
    .PARAMETER ImageName
        The generic image to base the container on.
 
    .PARAMETER MemoryLimit
        Optional memory limit (e.g. '8G'). Defaults to '8G' when not specified: Business Central's
        start script requires at least 3 GB, and under hyperv isolation (the Docker Desktop default
        on Windows client hosts) this value sizes the container VM, so leaving it unset makes the
        container exit immediately with "At least 3Gb memory needs to be available to the Container".
 
    .PARAMETER Isolation
        Container isolation: process or hyperv (default: let Docker decide).
 
    .PARAMETER LicenseFile
        Optional license file (path or URL) passed to the image.
 
    .PARAMETER Labels
        Additional Docker labels (hashtable).
 
    .PARAMETER PublishPorts
        Ports to publish (docker --publish values).
 
    .PARAMETER Http
        Serve the Web Client / Web Services over plain HTTP instead of the generic image's default
        self-signed HTTPS (sets the image env 'useSSL=N'). NavUserPassword works over HTTP. Intended
        for Sandbox/Dev containers on a trusted LAN only - credentials travel in clear text over HTTP.
 
    .PARAMETER Transparent
        Attach the container to a transparent Docker network so it gets its own LAN IP via DHCP and is
        reachable from other hosts on the LAN (no port publishing / firewall rule needed). The
        transparent network is created if the host does not already have one (see
        Initialize-BcTransparentNetwork). Dev-only; on a Hyper-V VM host the vNIC needs
        MACAddressSpoofing enabled.
 
    .PARAMETER Language
        Culture name (e.g. 'de-DE') to set as the container's default UI language (DefaultLanguage) after
        it is ready. Empty (default) leaves the image default (en-US). The language module must be
        installed on the instance (use a matching country artifact, e.g. country 'de' for de-DE) - a
        missing language throws. Setting this restarts the service tier (the key is not dynamically
        updatable). Ignored with -NoWait (the instance is not ready to configure).
 
    .PARAMETER SupportedLanguages
        Optional ';'-separated culture list to pin (SupportedLanguages). Default when -Language is set =
        the language alone, which forces the UI language even when the browser requests another installed
        language. Must include -Language. Only applies together with -Language.
 
    .PARAMETER EnvironmentVariables
        Additional environment variables (hashtable) merged into the image contract.
 
    .PARAMETER AdditionalArguments
        Extra raw 'docker run' arguments.
 
    .PARAMETER NoWait
        Do not wait for the container to become ready.
 
    .PARAMETER DockerExecutable
        The Docker executable to use (default 'docker').
 
    .EXAMPLE
        $cred = Get-Credential
        New-BcContainer -Name bld -ArtifactUrl (Find-BcArtifactUrl -Country w1 -Select Latest) -Credential $cred
 
    .OUTPUTS
        PSCustomObject describing the container.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Name,
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ArtifactUrl,
        [pscredential] $Credential,
        [ValidateSet('UserPassword', 'NavUserPassword', 'Windows', 'AAD')] [string] $Auth = 'UserPassword',
        [string] $ImageName = 'mcr.microsoft.com/businesscentral:ltsc2022',
        [string] $MemoryLimit,
        [ValidateSet('', 'process', 'hyperv')] [string] $Isolation = '',
        [string] $LicenseFile,
        [hashtable] $Labels = @{},
        [string[]] $PublishPorts = @(),
        [switch] $Http,
        [switch] $Transparent,
        [string] $Language = '',
        [string] $SupportedLanguages = '',
        [hashtable] $EnvironmentVariables = @{},
        [string[]] $AdditionalArguments = @(),
        [switch] $NoWait,
        # Persistent host folder shared into the container at C:\dl so the artifact is downloaded once
        # per host and reused by later containers. Default: ALBUILD_BCARTIFACT_CACHE env var, else the
        # BcArtifactCacheFolder config setting (C:\bcartifacts.cache).
        [string] $ArtifactCacheFolder,
        [switch] $NoArtifactCache,
        # Fail fast (before creating anything) when the drive Docker stores containers on has less than
        # this many GB free. A BC container copies the service tier + apps into its writable layer and
        # restores the demo database there, so a nearly-full drive makes the service tier fail to start -
        # surfacing as a cryptic "Failed to start service" only after a multi-minute wait. Set 0 to skip.
        [ValidateRange(0, [int]::MaxValue)] [int] $MinFreeDiskGb = 10,
        # Recreate-and-retry budget for a container that fails to become ready. The common cause on a flaky
        # link is a truncated/corrupt artifact download inside the image ("End of Central Directory record
        # could not be found"); each retry removes the dead container, re-validates the shared cache (drops
        # the incomplete artifact so the image re-downloads a clean copy) and recreates - riding out an
        # intermittently-failing download. 1 disables retrying.
        [ValidateRange(1, 10)] [int] $MaxStartAttempts = 3,
        [string] $DockerExecutable = 'docker'
    )

    Write-ALbuildLog "Creating Business Central container '$Name' from artifact '$ArtifactUrl' with image '$ImageName'..."

    Test-BcPlatform -Require | Out-Null
    Test-BcDocker -DockerExecutable $DockerExecutable -Require | Out-Null

    # Disk preflight: stop with an actionable message instead of a 5-10 minute wait that ends in the BC
    # image's cryptic "Failed to start service" when the drive is too full for the service tier + database.
    if ($MinFreeDiskGb -gt 0) {
        $disk = Get-BcHostFreeDiskGb -DockerExecutable $DockerExecutable
        if ($disk -and $disk.FreeGb -lt $MinFreeDiskGb) {
            throw "Insufficient disk space to create a Business Central container: only $($disk.FreeGb) GB free on drive $($disk.Drive) (Docker stores containers and the BC database there), need at least $MinFreeDiskGb GB. Free space on that drive - e.g. 'docker system prune -a --volumes', or remove old BC versions under the artifact cache - or move Docker's data-root / the artifact cache to a larger drive. Override this check with -MinFreeDiskGb 0."
        }
        if ($disk) { Write-ALbuildLog "Disk preflight: $($disk.FreeGb) GB free on $($disk.Drive) (minimum $MinFreeDiskGb GB)." }
    }

    # BC's start script enforces a >= 3 GB minimum; default to 8 GB so the container starts under
    # any isolation mode (under hyperv this value sizes the VM, not just a cap). Overridable.
    if ([string]::IsNullOrWhiteSpace($MemoryLimit)) { $MemoryLimit = '8G' }

    $containerEnv = @{
        'accept_eula' = 'Y'
        'artifactUrl' = $ArtifactUrl
        'Auth'        = $Auth
    }
    if ($Credential) {
        $containerEnv['username'] = $Credential.UserName
        $containerEnv['password'] = $Credential.GetNetworkCredential().Password
    }
    # HTTP-only: the BC generic image serves the Web Client over self-signed HTTPS by default; setting
    # useSSL=N makes it plain HTTP (works with NavUserPassword). Default (no -Http) leaves the image
    # default untouched. See Get-BcContainerWebClientUrl / docs for the Dev-only, LAN-only caveat.
    if ($Http) { $containerEnv['useSSL'] = 'N' }
    foreach ($key in $EnvironmentVariables.Keys) { $containerEnv[$key] = $EnvironmentVariables[$key] }

    # Stamp the resulting protocol + reachability as labels so later steps (inspect, remove, the URL
    # helper) can read the container's state without re-deriving it.
    $protocol = if ($Http) { 'http' } else { 'https' }
    $reachability = if ($Transparent) { 'transparent' } else { 'none' }
    $Labels['albuild.protocol'] = $protocol
    $Labels['albuild.reachability'] = $reachability

    if (-not $PSCmdlet.ShouldProcess($Name, "Create Business Central container from $ArtifactUrl")) { return }

    # Remove any pre-existing container of the same name so a re-run does not fail with a name
    # conflict ('The container name "/X" is already in use'). 'docker container inspect' exits 0 when
    # the container exists and 1 when it does not.
    $existing = Invoke-BcDocker -DockerExecutable $DockerExecutable -Quiet -PassThru -SuccessExitCodes @(0, 1) -Arguments @('container', 'inspect', $Name)
    if ($existing.ExitCode -eq 0) {
        Write-ALbuildLog "Container '$Name' already exists; removing it before recreating."
        Invoke-BcDocker -DockerExecutable $DockerExecutable -Quiet -Arguments @('rm', '--force', $Name) | Out-Null
    }

    # Bind-mount a host folder to C:\run\my so files (apps, packages) can be shared into the
    # container by writing to the host side - 'docker cp' is not supported against a running
    # hyperv-isolated container (the Docker Desktop default on Windows client hosts).
    $hostShare = Get-BcContainerHostShare -Name $Name
    if (Test-Path -LiteralPath $hostShare) { Remove-Item -LiteralPath $hostShare -Recurse -Force -ErrorAction SilentlyContinue }
    New-Item -ItemType Directory -Force -Path $hostShare | Out-Null
    $volumes = @("$($hostShare):C:\run\my")

    # Share a persistent host artifact cache into the container at C:\dl - the folder the BC image
    # downloads its artifact into and checks before downloading. The first container on a host fills it;
    # every later container finds the artifact already there and skips the multi-GB download. This is the
    # BcContainerHelper cache model (same layout, so an existing c:\bcartifacts.cache is reused too), and
    # it matters most on self-hosted agents with persistent storage. Disable with -NoArtifactCache.
    $artifactCache = $null   # always defined (StrictMode); the retry loop below re-validates it when set
    if (-not $NoArtifactCache) {
        $artifactCache = if ($ArtifactCacheFolder) { $ArtifactCacheFolder }
        elseif (-not [string]::IsNullOrWhiteSpace($env:ALBUILD_BCARTIFACT_CACHE)) { $env:ALBUILD_BCARTIFACT_CACHE }
        else { Get-ALbuildConfig -Name 'BcArtifactCacheFolder' }
        if (-not [string]::IsNullOrWhiteSpace($artifactCache)) {
            New-Item -ItemType Directory -Force -Path $artifactCache | Out-Null
            $volumes += "$($artifactCache):C:\dl"
            Write-ALbuildLog "Sharing artifact cache '$artifactCache' into the container at C:\dl (download once per host, reuse thereafter)."
            # Bulletproofing: an earlier interrupted download can leave an INCOMPLETE artifact in the
            # cache (folder present, no manifest.json). The image would then find the folder, skip the
            # re-download and exit with "Cannot find ...\manifest.json". Validate the cached artifact and
            # throw away any incomplete copy so this container re-downloads a clean one.
            Repair-BcArtifactCache -CacheFolder $artifactCache -ArtifactUrl $ArtifactUrl | Out-Null
        }
    }

    # A local license file must be made available *inside* the container - the host path (e.g. a
    # OneDrive folder) does not exist in the container, so the start script reports 'License File not
    # found'. Stage it into the bind-mounted share and reference the in-container path (URLs pass
    # through unchanged for the container to download).
    if ($LicenseFile) {
        $containerEnv['licenseFile'] = Resolve-BcContainerLicense -LicenseFile $LicenseFile -HostShare $hostShare
    }

    Write-ALbuildLog "Pulling image $ImageName ..."
    Invoke-BcDocker -DockerExecutable $DockerExecutable -RetryCount 5 -RetryDelaySeconds 15 -Arguments @('pull', $ImageName) | Out-Null

    # Transparent reachability: attach to a transparent Docker network (created on demand) so the
    # container gets its own LAN IP and is reachable from other hosts. Default = Docker's NAT (empty).
    $network = ''
    if ($Transparent) { $network = Initialize-BcTransparentNetwork -DockerExecutable $DockerExecutable }

    $runArgs = Get-BcContainerRunArguments -ImageName $ImageName -Name $Name -EnvironmentVariables $containerEnv `
        -MemoryLimit $MemoryLimit -Isolation $Isolation -Network $network -PublishPorts $PublishPorts -Labels $Labels `
        -Volumes $volumes -AdditionalArguments $AdditionalArguments -Detach $true

    # Create-and-retry. A container that never becomes ready is most often a transient/corrupt artifact
    # download inside the image on a flaky link; recreating (after dropping the incomplete cached artifact)
    # rides it out. This is stronger than BcContainerHelper's single -restartContainerAndRetry: up to
    # $MaxStartAttempts recreations, re-validating the shared cache each time. A -NoWait container is not
    # waited for, so it is created exactly once.
    $maxAttempts = if ($NoWait) { 1 } else { $MaxStartAttempts }
    for ($startAttempt = 1; $startAttempt -le $maxAttempts; $startAttempt++) {
        if ($startAttempt -gt 1) {
            # Tear down the dead container and re-validate the shared cache before recreating, so the image
            # re-downloads a clean artifact (Repair-BcArtifactCache drops any incomplete copy).
            Invoke-BcDocker -DockerExecutable $DockerExecutable -Quiet -SuccessExitCodes @(0, 1) -Arguments @('rm', '--force', $Name) | Out-Null
            if (-not [string]::IsNullOrWhiteSpace($artifactCache)) { Repair-BcArtifactCache -CacheFolder $artifactCache -ArtifactUrl $ArtifactUrl | Out-Null }
        }

        Write-ALbuildLog "Creating container $Name$(if ($maxAttempts -gt 1) { " (attempt $startAttempt/$maxAttempts)" }) ..."
        Invoke-BcDocker -DockerExecutable $DockerExecutable -Arguments $runArgs | Out-Null

        if ($NoWait) { break }

        Write-ALbuildLog "Waiting for container $Name to become ready (first run downloads the artifact; this can take several minutes)..."
        try {
            Wait-BcContainerReady -Name $Name -DockerExecutable $DockerExecutable | Out-Null
            break
        }
        catch {
            if ($startAttempt -ge $maxAttempts) {
                throw "Container '$Name' did not become ready after $maxAttempts attempt(s). If the container log shows a corrupt artifact ('End of Central Directory record could not be found'), the artifact download is being truncated - typically a proxy/firewall interfering with large downloads on the agent. $($_.Exception.Message)"
            }
            Write-ALbuildLog -Level Warning "Container '$Name' did not become ready on attempt $startAttempt/$maxAttempts ($($_.Exception.Message))."
            Write-ALbuildLog -Level Warning "Recreating the container and re-validating the artifact cache, then retrying..."
        }
    }

    # Set the container's default UI language once it is ready (the config keys are not dynamically
    # updatable, so Set-BcContainerLanguage restarts the instance). Only when -Language is given and the
    # container was waited for (a -NoWait instance is not ready to configure). A missing language throws.
    $appliedLanguage = $null
    $appliedSupportedLanguages = $null
    if ($Language -and -not $NoWait) {
        # Best-effort: the container is already created and healthy, so a language failure (e.g. the module
        # is not installed) must NOT abort creation and orphan the container - warn and keep it at the
        # image default (en-US). The caller sees Language=$null and can act on it.
        try {
            $langArgs = @{ Name = $Name; Language = $Language; ServerInstance = 'BC'; DockerExecutable = $DockerExecutable }
            if ($SupportedLanguages) { $langArgs['SupportedLanguages'] = $SupportedLanguages }
            $lang = Set-BcContainerLanguage @langArgs
            $appliedLanguage = $lang.Language
            $appliedSupportedLanguages = $lang.SupportedLanguages
        }
        catch {
            Write-ALbuildLog -Level Warning "Could not set the UI language '$Language' on '$Name': $($_.Exception.Message). The container stays at the default language (en-US)."
        }
    }
    elseif ($Language -and $NoWait) {
        Write-ALbuildLog -Level Warning "Ignoring -Language '$Language' because -NoWait was set; set it later with Set-BcContainerLanguage once the container is ready."
    }

    $container = Get-BcContainer -Name $Name -DockerExecutable $DockerExecutable

    # Enrich the returned object with the reachable Web Client URL + protocol/reachability + language so
    # callers (MCP ensure-container, the CLI, the browser agent) get a usable address. Best-effort: a
    # container started with -NoWait may not have an IP yet, in which case the URL fields are $null.
    $web = $null
    try { $web = Get-BcContainerWebClientUrl -Name $Name -Protocol $protocol -DockerExecutable $DockerExecutable }
    catch { Write-ALbuildLog -Level Warning "Could not resolve the Web Client URL for '$Name': $($_.Exception.Message)" }
    $container | Add-Member -NotePropertyMembers @{
        Protocol           = $protocol
        Reachability       = $reachability
        WebClientUrl       = if ($web) { $web.Url } else { $null }
        IpAddress          = if ($web) { $web.Ip } else { $null }
        Port               = if ($web) { $web.Port } else { $null }
        Language           = $appliedLanguage
        SupportedLanguages = $appliedSupportedLanguages
    } -Force
    return $container
}