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 # Self-heal a full drive before failing. ALbuild's caches grow unbounded - a multi-GB entry per BC # version (BcArtifactCacheFolder / ArtifactCacheFolder / PackageCacheFolder) plus Docker images # orphaned by repeated 'docker pull' - and nothing trims them on its own, so a long-lived # self-hosted agent fills up over time. Rather than fail with "free the disk yourself", prune the # OLD cache entries (keeping the newest per cache, so the version this build needs is untouched) # and dangling Docker images, then re-measure. This only runs when already below the floor (the # build would otherwise fail here), so it never deletes anything on a healthy agent. The scheduled # CleanupCaches task is still the primary retention mechanism; this is the last-resort safety net. # Skip the whole preflight (and this) with -MinFreeDiskGb 0. if ($disk -and $disk.FreeGb -lt $MinFreeDiskGb) { Write-ALbuildLog -Level Warning "Only $($disk.FreeGb) GB free on $($disk.Drive) (need $MinFreeDiskGb GB). Reclaiming space: pruning ALbuild caches older than 3 days (keeping the newest per cache) and dangling Docker images..." if (Get-Command -Name Clear-ALbuildCache -ErrorAction SilentlyContinue) { try { $pruned = Clear-ALbuildCache -KeepLatest 1 -KeepDays 3 -IncludeDocker -Confirm:$false Write-ALbuildLog "Reclaimed $($pruned.TotalFreedText) by pruning old caches." } catch { Write-ALbuildLog -Level Warning "Automatic cache prune failed: $($_.Exception.Message)" } $disk = Get-BcHostFreeDiskGb -DockerExecutable $DockerExecutable } else { Write-ALbuildLog -Level Warning "Clear-ALbuildCache is not available to auto-reclaim; add a CleanupCaches pipeline step or free the drive manually." } } 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 - and an automatic prune of old caches did not free enough. Free space on that drive - e.g. 'docker system prune -a --volumes', or 'Clear-ALbuildCache -KeepLatest 1 -KeepDays 0 -IncludeDocker' - 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 } |