< Summary - Kestrun — Combined Coverage

Information
Class: Public.Service.New-KrDockerDeployment
Assembly: Kestrun.PowerShell.Public
File(s): /home/runner/work/Kestrun/Kestrun/src/PowerShell/Kestrun/Public/Service/New-KrDockerDeployment.ps1
Tag: Kestrun/Kestrun@476fba12d8b4c7db258e9ff68fad76f0d7e4e042
Line coverage
92%
Covered lines: 211
Uncovered lines: 17
Coverable lines: 228
Total lines: 603
Line coverage: 92.5%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 05/04/2026 - 03:10:25 Line coverage: 92.5% (211/228) Total lines: 603 Tag: Kestrun/Kestrun@476fba12d8b4c7db258e9ff68fad76f0d7e4e042

Metrics

File(s)

/home/runner/work/Kestrun/Kestrun/src/PowerShell/Kestrun/Public/Service/New-KrDockerDeployment.ps1

#LineLine coverage
 1<#
 2.SYNOPSIS
 3    Creates a Docker Compose deployment bundle from a Kestrun service package.
 4.DESCRIPTION
 5    Expands a `.krpack` service package, validates its `Service.psd1` descriptor,
 6    and generates a self-contained Docker deployment bundle that includes:
 7
 8    - `docker-compose.yml`
 9    - `Dockerfile`
 10    - `entrypoint.sh`
 11    - the input `.krpack` copied as `app.krpack`
 12    - a local copy of the current `Kestrun` PowerShell module
 13    - `.dockerignore`
 14
 15    The generated container uses the Microsoft ASP.NET Core .NET 10 base image
 16    and installs PowerShell from the Microsoft Linux package repository.
 17.PARAMETER PackagePath
 18    Path to the `.krpack` service package.
 19.PARAMETER OutputPath
 20    Output directory for the generated deployment bundle.
 21    Defaults to `<service-name>-docker` in the current directory.
 22.PARAMETER ImageName
 23    Docker image name written to `docker-compose.yml`.
 24    Defaults to `kestrun-<service-name-normalized>:<version>`.
 25.PARAMETER ServiceName
 26    Docker Compose service name and container name.
 27    Defaults to the service descriptor name normalized to lowercase kebab-case.
 28.PARAMETER PublishedPort
 29    Host port mapped by Docker Compose.
 30    Defaults to `8080`.
 31.PARAMETER ContainerPort
 32    Container port exposed by the generated image and used by `ASPNETCORE_URLS`.
 33    Defaults to `8080`.
 34.PARAMETER KestrunModulePath
 35    Optional path to the `Kestrun` module root folder to stage into the deployment bundle.
 36    Defaults to the currently loaded module source folder.
 37.PARAMETER Force
 38    Overwrite an existing generated deployment bundle.
 39.PARAMETER WhatIf
 40    Shows what would happen if the cmdlet runs. The cmdlet is not executed.
 41.PARAMETER Confirm
 42    Prompts for confirmation before running the cmdlet.
 43.EXAMPLE
 44    New-KrDockerDeployment -PackagePath .\my-service.krpack
 45.EXAMPLE
 46    New-KrDockerDeployment -PackagePath .\my-service.krpack -PublishedPort 5000 -OutputPath .\deploy\docker
 47.EXAMPLE
 48    New-KrDockerDeployment -PackagePath .\my-service.krpack -ImageName 'my-registry/my-service:1.2.0' -Force
 49#>
 50function New-KrDockerDeployment {
 51    [KestrunRuntimeApi('Everywhere')]
 52    [CmdletBinding(SupportsShouldProcess)]
 53    [OutputType([pscustomobject])]
 54    param(
 55        [Parameter(Mandatory)]
 56        [ValidateNotNullOrEmpty()]
 57        [string]$PackagePath,
 58
 59        [Parameter()]
 60        [string]$OutputPath,
 61
 62        [Parameter()]
 63        [string]$ImageName,
 64
 65        [Parameter()]
 66        [string]$ServiceName,
 67
 68        [Parameter()]
 69        [ValidateRange(1, 65535)]
 70        [int]$PublishedPort = 8080,
 71
 72        [Parameter()]
 73        [ValidateRange(1, 65535)]
 74        [int]$ContainerPort = 8080,
 75
 76        [Parameter()]
 77        [string]$KestrunModulePath,
 78
 79        [Parameter()]
 80        [switch]$Force
 81    )
 82
 183    $declaringModuleBase = if ($MyInvocation.MyCommand.Module) {
 184        $MyInvocation.MyCommand.Module.ModuleBase
 85    } else {
 086        $null
 87    }
 88
 89    function Get-KrDefaultModuleRoot {
 90        <#
 91        .SYNOPSIS
 92            Resolves the default Kestrun module root path based on the current script location.
 93        .DESCRIPTION
 94            Resolves the module root from the current module base when available and
 95            falls back to nearby script locations when running from a source checkout.
 96            Validates that the resolved path contains `Kestrun.psd1` to ensure it is correct.
 97        .OUTPUTS
 98            String path to the Kestrun module root.
 99        #>
 1100        $candidateRoots = [System.Collections.Generic.List[string]]::new()
 1101        foreach ($candidateRoot in @(
 1102                $declaringModuleBase
 1103                $ExecutionContext.SessionState.Module.ModuleBase
 1104                $PSScriptRoot
 2105                (Split-Path -Parent $PSScriptRoot)
 3106                (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
 107            )) {
 1108            if ([string]::IsNullOrWhiteSpace($candidateRoot)) {
 109                continue
 110            }
 111
 1112            $resolvedCandidateRoot = [System.IO.Path]::GetFullPath($candidateRoot)
 1113            if (-not $candidateRoots.Contains($resolvedCandidateRoot)) {
 1114                $candidateRoots.Add($resolvedCandidateRoot)
 115            }
 116        }
 117
 1118        foreach ($candidateRoot in $candidateRoots) {
 2119            if (Test-Path -LiteralPath (Join-Path -Path $candidateRoot -ChildPath 'Kestrun.psd1') -PathType Leaf) {
 1120                return $candidateRoot
 121            }
 122        }
 123
 0124        throw "Unable to resolve the Kestrun module root from '$PSScriptRoot'."
 125    }
 126
 127    function Get-KrNormalizedDockerName {
 128        <#
 129        .SYNOPSIS
 130            Normalizes a string to a valid Docker name.
 131        .DESCRIPTION
 132            Converts the input string to lowercase, replaces invalid characters with hyphens, and trims leading/trailing
 133            If the result is empty, returns a fallback name.
 134        .PARAMETER Name
 135            The input string to normalize.
 136        .PARAMETER Fallback
 137            The fallback name to use if normalization results in an empty string.
 138        .OUTPUTS
 139            String containing the normalized Docker name.
 140        #>
 141        param(
 142            [Parameter(Mandatory)]
 143            [string]$Name,
 144
 145            [string]$Fallback = 'kestrun-app'
 146        )
 147
 1148        $normalized = $Name.ToLowerInvariant()
 1149        $normalized = [System.Text.RegularExpressions.Regex]::Replace($normalized, '[^a-z0-9]+', '-')
 1150        $normalized = $normalized.Trim('-')
 151
 1152        if ([string]::IsNullOrWhiteSpace($normalized)) {
 0153            return $Fallback
 154        }
 155
 1156        return $normalized
 157    }
 158
 159    function Get-KrStableDockerSuffix {
 160        <#
 161        .SYNOPSIS
 162            Generates a stable suffix for Docker resource names based on an input string.
 163        .DESCRIPTION
 164            Computes a SHA256 hash of the input string and returns the first 12 characters as a hexadecimal suffix.
 165            This ensures that the same input will always produce the same suffix, which is useful for generating consist
 166        .PARAMETER InputValue
 167            The input string used to generate the hash-based suffix.
 168        .OUTPUTS
 169            String containing the stable Docker suffix derived from the input value.
 170        #>
 171        param(
 172            [Parameter(Mandatory)]
 173            [string]$InputValue
 174        )
 175
 1176        $bytes = [System.Text.Encoding]::UTF8.GetBytes($InputValue)
 1177        $hash = [System.Security.Cryptography.SHA256]::HashData($bytes)
 2178        return ([System.Convert]::ToHexString($hash)).Substring(0, 12).ToLowerInvariant()
 179    }
 180
 181    function Get-KrApplicationDataDefinition {
 182        <#
 183        .SYNOPSIS
 184            Generates application data volume definitions for Docker Compose based on service descriptor entries.
 185        .DESCRIPTION
 186            For each relative path specified in the service descriptor's
 187            `ApplicationDataFolders`, this function generates a corresponding Docker
 188            volume name and storage path. Each path must point to a subdirectory
 189            under the service root.
 190        .PARAMETER NormalizedServiceName
 191            The normalized name of the service, used as a prefix for volume names.
 192        .PARAMETER RelativePaths
 193            An array of relative paths from the service descriptor's
 194            `ApplicationDataFolders` entry. Each path is processed to generate a
 195            corresponding Docker volume definition.
 196        .OUTPUTS
 197            An array of custom objects containing `RelativePath`, `VolumeName`, and
 198            `StoragePath` properties for use in Docker Compose volume definitions.
 199        #>
 200        param(
 201            [Parameter(Mandatory)]
 202            [string]$NormalizedServiceName,
 203
 204            [Parameter()]
 205            [string[]]$RelativePaths
 206        )
 207
 1208        $definitions = [System.Collections.Generic.List[object]]::new()
 2209        foreach ($relativePath in @($RelativePaths)) {
 1210            if ([string]::IsNullOrWhiteSpace($relativePath)) {
 211                continue
 212            }
 213
 1214            $normalizedRelativePath = $relativePath.Replace('\\', '/').Trim()
 1215            $trimmedRelativePath = $normalizedRelativePath.Trim('/')
 2216            $pathSegments = @($trimmedRelativePath -split '/')
 217
 1218            if ($pathSegments -contains '.' -or $pathSegments -contains '..') {
 1219                throw (
 1220                    "ApplicationDataFolders entry '{0}' must resolve to a subdirectory under the service root." -f
 221                    $relativePath
 222                )
 223            }
 224
 1225            if ([string]::IsNullOrWhiteSpace($trimmedRelativePath)) {
 226                continue
 227            }
 228
 2229            $dockerSegment = Get-KrNormalizedDockerName -Name ($trimmedRelativePath -replace '/', '-') -Fallback 'app-da
 1230            $hashSuffix = Get-KrStableDockerSuffix -InputValue $trimmedRelativePath.ToLowerInvariant()
 1231            $volumeName = '{0}-appdata-{1}-{2}' -f $NormalizedServiceName, $dockerSegment, $hashSuffix
 1232            $storagePath = '/opt/kestrun/application-data/{0}-{1}' -f $dockerSegment, $hashSuffix
 233
 2234            $definitions.Add([pscustomobject]([ordered]@{
 1235                        RelativePath = $normalizedRelativePath
 1236                        VolumeName = $volumeName
 1237                        StoragePath = $storagePath
 238                    }))
 239        }
 240
 1241        return $definitions
 242    }
 243
 244    function Get-KrDeploymentOutputPath {
 245        <#
 246        .SYNOPSIS
 247            Resolves the output path for the Docker deployment bundle.
 248        .DESCRIPTION
 249            If a path is provided, it returns the full path. Otherwise, it combines the current location with a default 
 250        .PARAMETER ProvidedOutputPath
 251            The user-provided output path.
 252        .PARAMETER DefaultDirectoryName
 253            The default directory name to use if no path is provided.
 254        .OUTPUTS
 255            String containing the resolved output path.
 256        #>
 257        param(
 258            [string]$ProvidedOutputPath,
 259            [string]$DefaultDirectoryName
 260        )
 261
 1262        if ([string]::IsNullOrWhiteSpace($ProvidedOutputPath)) {
 0263            return [System.IO.Path]::Combine((Get-Location).Path, $DefaultDirectoryName)
 264        }
 265
 1266        return [System.IO.Path]::GetFullPath($ProvidedOutputPath)
 267    }
 268
 269    function Set-KrGeneratedFileContent {
 270        <#
 271        .SYNOPSIS
 272            Writes content to a file, ensuring the directory exists and handling overwrites based on the Force parameter
 273        .DESCRIPTION
 274            Creates the target directory if it does not exist. If the file already
 275            exists and Force is not set, it throws an error. Writes the content
 276            using UTF-8 encoding without a BOM.
 277        .PARAMETER Path
 278            The file path where the content should be written.
 279        .PARAMETER Content
 280            The content to write to the file.
 281        .OUTPUTS
 282            Boolean indicating whether the file was written.
 283        #>
 284        [CmdletBinding(SupportsShouldProcess)]
 285        param(
 286            [Parameter(Mandatory)]
 287            [string]$Path,
 288
 289            [Parameter(Mandatory)]
 290            [string]$Content
 291        )
 292
 1293        if (-not $PSCmdlet.ShouldProcess($Path, 'Write generated file content')) {
 0294            return $false
 295        }
 296
 1297        $directory = [System.IO.Path]::GetDirectoryName($Path)
 2298        if (-not [string]::IsNullOrWhiteSpace($directory) -and -not (Test-Path -LiteralPath $directory -PathType Contain
 0299            $null = New-Item -ItemType Directory -Path $directory -Force
 300        }
 301
 2302        if ((Test-Path -LiteralPath $Path -PathType Leaf) -and -not $Force) {
 0303            throw "Output file already exists: $Path. Use -Force to overwrite."
 304        }
 305
 1306        $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
 1307        [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom)
 308
 1309        return $true
 310    }
 311
 312    function Copy-KrGeneratedDirectory {
 313        <#
 314        .SYNOPSIS
 315            Copies a directory and its contents, handling overwrites based on the Force parameter.
 316        .DESCRIPTION
 317            If the destination directory already exists and Force is not set, it throws an error. Otherwise, it removes 
 318        .PARAMETER SourcePath
 319            The path of the source directory to copy.
 320        .PARAMETER DestinationPath
 321            The path of the destination directory.
 322        .OUTPUTS
 323            Boolean indicating whether the directory was copied.
 324        #>
 325        [CmdletBinding(SupportsShouldProcess)]
 326        param(
 327            [Parameter(Mandatory)]
 328            [string]$SourcePath,
 329
 330            [Parameter(Mandatory)]
 331            [string]$DestinationPath
 332        )
 333
 1334        if (-not $PSCmdlet.ShouldProcess($DestinationPath, 'Copy generated directory contents')) {
 0335            return $false
 336        }
 337
 1338        if (Test-Path -LiteralPath $DestinationPath) {
 0339            if (-not $Force) {
 0340                throw "Output directory already exists: $DestinationPath. Use -Force to overwrite."
 341            }
 342
 0343            Remove-Item -LiteralPath $DestinationPath -Recurse -Force
 344        }
 345
 1346        Copy-Item -LiteralPath $SourcePath -Destination $DestinationPath -Recurse -Force
 347
 1348        return $true
 349    }
 350
 1351    $temporaryExtractionRoot = $null
 352
 353    try {
 1354        $resolvedPackagePath = [System.IO.Path]::GetFullPath($PackagePath)
 2355        if (-not (Test-Path -LiteralPath $resolvedPackagePath -PathType Leaf)) {
 0356            throw "Package file not found: $resolvedPackagePath"
 357        }
 358
 1359        if (-not $resolvedPackagePath.EndsWith('.krpack', [System.StringComparison]::OrdinalIgnoreCase)) {
 0360            throw "PackagePath must point to a .krpack file: $resolvedPackagePath"
 361        }
 362
 1363        $resolvedModuleRoot = if ([string]::IsNullOrWhiteSpace($KestrunModulePath)) {
 1364            Get-KrDefaultModuleRoot
 365        } else {
 0366            [System.IO.Path]::GetFullPath($KestrunModulePath)
 367        }
 368
 2369        if (-not (Test-Path -LiteralPath $resolvedModuleRoot -PathType Container)) {
 0370            throw "Kestrun module path not found: $resolvedModuleRoot"
 371        }
 372
 3373        if (-not (Test-Path -LiteralPath (Join-Path -Path $resolvedModuleRoot -ChildPath 'Kestrun.psd1') -PathType Leaf)
 0374            throw "Kestrun module manifest not found under: $resolvedModuleRoot"
 375        }
 376
 3377        $temporaryExtractionRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('kestrun-docker-{0}' -f [Guid]::NewGuid(
 1378        $null = New-Item -ItemType Directory -Path $temporaryExtractionRoot -Force
 1379        Expand-Archive -LiteralPath $resolvedPackagePath -DestinationPath $temporaryExtractionRoot -Force
 380
 1381        $descriptor = Get-KrServiceDescriptor -Path $temporaryExtractionRoot
 1382        $normalizedServiceName = if ([string]::IsNullOrWhiteSpace($ServiceName)) {
 1383            Get-KrNormalizedDockerName -Name $descriptor.Name -Fallback 'kestrun-app'
 384        } else {
 1385            Get-KrNormalizedDockerName -Name $ServiceName -Fallback 'kestrun-app'
 386        }
 387
 1388        $resolvedImageName = if ([string]::IsNullOrWhiteSpace($ImageName)) {
 1389            'kestrun-{0}:{1}' -f $normalizedServiceName, $descriptor.Version
 390        } else {
 1391            $ImageName
 392        }
 393
 2394        $resolvedOutputPath = Get-KrDeploymentOutputPath -ProvidedOutputPath $OutputPath -DefaultDirectoryName ('{0}-doc
 1395        $resolvedPowerShellPackageVersion = '7.6.0-1.deb'
 1396        $composePath = Join-Path -Path $resolvedOutputPath -ChildPath 'docker-compose.yml'
 1397        $dockerfilePath = Join-Path -Path $resolvedOutputPath -ChildPath 'Dockerfile'
 1398        $entrypointPath = Join-Path -Path $resolvedOutputPath -ChildPath 'entrypoint.sh'
 1399        $dockerignorePath = Join-Path -Path $resolvedOutputPath -ChildPath '.dockerignore'
 1400        $packageDestinationPath = Join-Path -Path $resolvedOutputPath -ChildPath 'app.krpack'
 1401        $moduleDestinationPath = Join-Path -Path $resolvedOutputPath -ChildPath 'Kestrun'
 2402        $applicationDataDefinitions = @(Get-KrApplicationDataDefinition -NormalizedServiceName $normalizedServiceName -R
 403
 1404        $composeLines = [System.Collections.Generic.List[string]]::new()
 1405        $composeLines.Add('services:')
 1406        $composeLines.Add("  ${normalizedServiceName}:")
 1407        $composeLines.Add("    image: $resolvedImageName")
 1408        $composeLines.Add('    build:')
 1409        $composeLines.Add('      context: .')
 1410        $composeLines.Add('      dockerfile: Dockerfile')
 1411        $composeLines.Add('    ports:')
 2412        $composeLines.Add(('      - "{0}:{1}"' -f $PublishedPort, $ContainerPort))
 1413        $composeLines.Add('    environment:')
 2414        $composeLines.Add(('      PORT: "{0}"' -f $ContainerPort))
 2415        $composeLines.Add(('      ASPNETCORE_URLS: "http://+:{0}"' -f $ContainerPort))
 1416        if ($applicationDataDefinitions.Count -gt 0) {
 1417            $composeLines.Add('    volumes:')
 1418            foreach ($applicationDataDefinition in $applicationDataDefinitions) {
 3419                $composeLines.Add("      - $($applicationDataDefinition.VolumeName):$($applicationDataDefinition.Storage
 420            }
 421        }
 422
 1423        $composeLines.Add('    restart: unless-stopped')
 424
 1425        if ($applicationDataDefinitions.Count -gt 0) {
 1426            $composeLines.Add('volumes:')
 1427            foreach ($applicationDataDefinition in $applicationDataDefinitions) {
 2428                $composeLines.Add("  $($applicationDataDefinition.VolumeName):")
 429            }
 430        }
 431
 1432        $composeContent = $composeLines -join "`n"
 433
 1434        $dockerfileContent = @"
 435FROM mcr.microsoft.com/dotnet/aspnet:10.0
 436
 437ARG POWERSHELL_PACKAGE_VERSION=$resolvedPowerShellPackageVersion
 438
 439RUN apt-get update \
 440    && apt-get install -y --no-install-recommends wget ca-certificates \
 441    && . /etc/os-release \
 442    && wget -q "https://packages.microsoft.com/config/`${ID}/`${VERSION_ID}/packages-microsoft-prod.deb" \
 443    && dpkg -i packages-microsoft-prod.deb \
 444    && rm packages-microsoft-prod.deb \
 445    && apt-get update \
 446    && apt-get install -y --no-install-recommends powershell=`${POWERSHELL_PACKAGE_VERSION} \
 447    && apt-get clean \
 448    && rm -rf /var/lib/apt/lists/*
 449
 450ENV PORT=$ContainerPort
 451ENV ASPNETCORE_URLS=http://+:$ContainerPort
 452WORKDIR /opt/kestrun
 453
 454COPY Kestrun/ /opt/kestrun/Kestrun/
 455COPY app.krpack /opt/kestrun/app/app.krpack
 456COPY entrypoint.sh /opt/kestrun/entrypoint.sh
 457
 458RUN module_root="`$(pwsh -NoLogo -NoProfile -Command '(`$env:PSModulePath -split [System.IO.Path]::PathSeparator)[0]')" 
 459    && module_version="`$(pwsh -NoLogo -NoProfile -Command '(Import-PowerShellDataFile -LiteralPath "/opt/kestrun/Kestru
 460    && mkdir -p "`$module_root/Kestrun/`$module_version" \
 461    && cp -R /opt/kestrun/Kestrun/. "`$module_root/Kestrun/`$module_version/" \
 462    && rm -rf /opt/kestrun/Kestrun \
 463    && printf '%s\n' 'if (Get-Module -ListAvailable Kestrun) {' '    Import-Module Kestrun' '}' > /opt/microsoft/powersh
 464    && chmod +x /opt/kestrun/entrypoint.sh
 465
 466EXPOSE $ContainerPort
 467
 468ENTRYPOINT ["/opt/kestrun/entrypoint.sh"]
 469"@
 470
 1471        $entrypointLines = [System.Collections.Generic.List[string]]::new()
 1472        @(
 1473            '#!/bin/sh'
 1474            'set -eu'
 1475            ''
 1476            'PACKAGE_PATH="/opt/kestrun/app/app.krpack"'
 1477            'SERVICE_ROOT="/opt/kestrun/service"'
 1478            'PERSISTENT_ROOT="/opt/kestrun/application-data"'
 1479            'ENTRYPOINT_FILE="/opt/kestrun/app/entrypoint-path.txt"'
 1480            ''
 1481            'mkdir -p "$PERSISTENT_ROOT"'
 1482            ''
 1483            "pwsh -NoLogo -NoProfile -File - <<'POWERSHELL'"
 1484            '$ErrorActionPreference = ''Stop'''
 1485            '$packagePath = ''/opt/kestrun/app/app.krpack'''
 1486            '$serviceRoot = ''/opt/kestrun/service'''
 1487            '$entrypointFile = ''/opt/kestrun/app/entrypoint-path.txt'''
 1488            '$serviceRootWithSeparator = ([System.IO.Path]::GetFullPath($serviceRoot)).TrimEnd('
 1489            '    [System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) +'
 1490            '    [System.IO.Path]::DirectorySeparatorChar'
 1491            '$applicationDataDefinitions = @('
 1492        ).ForEach({ $entrypointLines.Add($_) })
 493
 1494        foreach ($applicationDataDefinition in $applicationDataDefinitions) {
 3495            $entrypointLines.Add("    [pscustomobject]@{ RelativePath = '$($applicationDataDefinition.RelativePath.Repla
 496        }
 497
 1498        @(
 1499            ')'
 1500            'if (Test-Path -LiteralPath $serviceRoot) {'
 1501            '    Remove-Item -LiteralPath $serviceRoot -Recurse -Force'
 1502            '}'
 1503            '$null = New-Item -ItemType Directory -Path $serviceRoot -Force'
 1504            'Expand-Archive -LiteralPath $packagePath -DestinationPath $serviceRoot -Force'
 1505            '$descriptorPath = [System.IO.Path]::Combine($serviceRoot, ''Service.psd1'')'
 1506            '$descriptor = Import-PowerShellDataFile -LiteralPath $descriptorPath'
 1507            'if (-not $descriptor.ContainsKey(''EntryPoint'') -or [string]::IsNullOrWhiteSpace([string]$descriptor[''Ent
 1508            '    throw (''Descriptor {0} is missing required key EntryPoint.'' -f $descriptorPath)'
 1509            '}'
 1510            'foreach ($applicationDataDefinition in $applicationDataDefinitions) {'
 1511            '    $relativePath = [string]$applicationDataDefinition.RelativePath'
 1512            '    $storagePath = [string]$applicationDataDefinition.StoragePath'
 1513            '    $servicePath = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($serviceRoot, $relativePath))'
 1514            '    if ($servicePath -eq $serviceRoot -or -not $servicePath.StartsWith($serviceRootWithSeparator, [System.S
 1515            '        throw (''ApplicationDataFolders entry ''''{0}'''' must resolve to a subdirectory under the service 
 1516            '    }'
 1517            '    $storageDirectory = [System.IO.Path]::GetDirectoryName($storagePath)'
 1518            '    if (-not [string]::IsNullOrWhiteSpace($storageDirectory)) {'
 1519            '        $null = New-Item -ItemType Directory -Path $storageDirectory -Force'
 1520            '    }'
 1521            '    if (-not (Test-Path -LiteralPath $storagePath -PathType Container)) {'
 1522            '        $null = New-Item -ItemType Directory -Path $storagePath -Force'
 1523            '    }'
 1524            '    $storageChildren = @(Get-ChildItem -LiteralPath $storagePath -Force -ErrorAction SilentlyContinue)'
 1525            '    if ((Test-Path -LiteralPath $servicePath -PathType Container) -and $storageChildren.Count -eq 0) {'
 1526            '        foreach ($child in Get-ChildItem -LiteralPath $servicePath -Force -ErrorAction SilentlyContinue) {'
 1527            '            Copy-Item -LiteralPath $child.FullName -Destination $storagePath -Recurse -Force'
 1528            '        }'
 1529            '    }'
 1530            '    if (Test-Path -LiteralPath $servicePath) {'
 1531            '        Remove-Item -LiteralPath $servicePath -Recurse -Force'
 1532            '    }'
 1533            '    $serviceDirectory = [System.IO.Path]::GetDirectoryName($servicePath)'
 1534            '    if (-not [string]::IsNullOrWhiteSpace($serviceDirectory)) {'
 1535            '        $null = New-Item -ItemType Directory -Path $serviceDirectory -Force'
 1536            '    }'
 1537            '    $null = New-Item -ItemType SymbolicLink -Path $servicePath -Target $storagePath'
 1538            '}'
 1539            '$entryPointPath = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($serviceRoot, [string]$descriptor
 1540            '[System.IO.File]::WriteAllText($entrypointFile, $entryPointPath, [System.Text.UTF8Encoding]::new($false))'
 1541            'POWERSHELL'
 1542            ''
 1543            'ENTRYPOINT_PATH=$(cat "$ENTRYPOINT_FILE")'
 1544            ''
 2545            ('export ASPNETCORE_URLS="${{ASPNETCORE_URLS:-http://+:{0}}}"' -f $ContainerPort)
 2546            ('export PORT="${{PORT:-{0}}}"' -f $ContainerPort)
 1547            ''
 1548            'cd "$SERVICE_ROOT"'
 1549            'exec pwsh -NoLogo -File "$ENTRYPOINT_PATH" "$@"'
 1550        ).ForEach({ $entrypointLines.Add($_) })
 1551        $entrypointContent = $entrypointLines -join "`n"
 552
 1553        $dockerignoreContent = @'
 554*
 555!Dockerfile
 556!docker-compose.yml
 557!entrypoint.sh
 558!app.krpack
 559!Kestrun/
 560!Kestrun/**
 561'@
 562
 1563        if (-not $PSCmdlet.ShouldProcess($resolvedOutputPath, 'Create Docker deployment bundle')) {
 564            return
 565        }
 566
 2567        if (-not (Test-Path -LiteralPath $resolvedOutputPath -PathType Container)) {
 1568            $null = New-Item -ItemType Directory -Path $resolvedOutputPath -Force
 569        }
 570
 2571        Set-KrGeneratedFileContent -Path $composePath -Content $composeContent -Confirm:$false -WhatIf:$false | Out-Null
 2572        Set-KrGeneratedFileContent -Path $dockerfilePath -Content $dockerfileContent -Confirm:$false -WhatIf:$false | Ou
 2573        Set-KrGeneratedFileContent -Path $entrypointPath -Content $entrypointContent -Confirm:$false -WhatIf:$false | Ou
 2574        Set-KrGeneratedFileContent -Path $dockerignorePath -Content $dockerignoreContent -Confirm:$false -WhatIf:$false 
 575
 2576        if ((Test-Path -LiteralPath $packageDestinationPath -PathType Leaf) -and -not $Force) {
 0577            throw "Output file already exists: $packageDestinationPath. Use -Force to overwrite."
 578        }
 579
 1580        Copy-Item -LiteralPath $resolvedPackagePath -Destination $packageDestinationPath -Force
 2581        Copy-KrGeneratedDirectory -SourcePath $resolvedModuleRoot -DestinationPath $moduleDestinationPath -Confirm:$fals
 582
 2583        [pscustomobject]([ordered]@{
 1584                PackagePath = $resolvedPackagePath
 1585                DeploymentPath = $resolvedOutputPath
 1586                ComposePath = $composePath
 1587                DockerfilePath = $dockerfilePath
 1588                EntrypointPath = $entrypointPath
 1589                DockerignorePath = $dockerignorePath
 1590                ServiceName = $normalizedServiceName
 1591                ImageName = $resolvedImageName
 1592                DescriptorName = $descriptor.Name
 1593                Version = $descriptor.Version
 1594                EntryPoint = $descriptor.EntryPoint
 1595                PublishedPort = $PublishedPort
 1596                ContainerPort = $ContainerPort
 597            })
 598    } finally {
 2599        if (-not [string]::IsNullOrWhiteSpace($temporaryExtractionRoot) -and (Test-Path -LiteralPath $temporaryExtractio
 1600            Remove-Item -LiteralPath $temporaryExtractionRoot -Recurse -Force -ErrorAction SilentlyContinue
 601        }
 602    }
 603}