Docker-BuildAgent.psm1
|
#Requires -Version 5.1 [CmdletBinding()] param() # --- Module Configuration --- # The default image is pinned to this module's own version (never `latest`); an override is used verbatim. # A pre-release module (PSData Prerelease = 'rc1') pins the image of the same pre-release, 2.1.0-rc1. $script:ModuleManifestText = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'Docker-BuildAgent.psd1') -Raw $script:ModuleVersion = [regex]::Match($script:ModuleManifestText, "(?m)^\s*ModuleVersion\s*=\s*'([^']+)'").Groups[1].Value $script:ModulePrerelease = [regex]::Match($script:ModuleManifestText, "(?m)^\s*Prerelease\s*=\s*'([^']+)'").Groups[1].Value if ($script:ModulePrerelease) { $script:ModuleVersion = "$script:ModuleVersion-$script:ModulePrerelease" } $script:BuildAgentConfig = @{ DockerImage = "ghcr.io/the-running-dev/build-agent:$script:ModuleVersion" DockerHost = "tcp://host.docker.internal:2375" WorkspacePath = $PSScriptRoot ArtifactsDir = "artifacts" Environment = "development" Parameters = @{} } # --- Configuration Management --- function Set-BuildAgentConfig { <# .SYNOPSIS Sets the configuration that Invoke-Build uses for the rest of the session. .DESCRIPTION Replaces every field of the session's build-agent configuration: the image to run, the Docker daemon the build talks to, the workspace to build, the artifacts directory, the environment label and the build parameters applied to every build. A field you do not pass is reset to the default shown for its parameter, not left at its previous value. The configuration is held in the module for the current session and is also visible as the exported $BuildAgentConfig variable. Before this is called it holds the module defaults: the image pinned to this module's own version (never latest), the daemon tcp://host.docker.internal:2375, the module directory as the workspace, an artifacts directory of "artifacts", the development environment and no parameters. The workspace path is checked before anything is changed, so a rejected call leaves the previous configuration in place. On success the command writes "[OK] Build Agent Configuration Updated." to the host. .PARAMETER DockerImage The image to run, used exactly as given. A reference you pass is never replaced with another version or with latest. Mandatory. .PARAMETER DockerHost The Docker daemon the build talks to, passed to the container as DOCKER_HOST. It must look like tcp://host:port, unix:///path or npipe:////./pipe/name, or parameter validation rejects it. For a unix:// host, Invoke-Build also mounts the socket into the container. Mandatory. .PARAMETER WorkspacePath The directory to build. It is mounted at /workspace in the container and must exist as a directory, otherwise the command fails with the WorkspaceInvalid error (exit status 3). A relative path is resolved against the current directory and stored as an absolute path. Mandatory. .PARAMETER ArtifactsDir The output directory for the node, node-in-docker and node-template build types. Invoke-Build passes it as the artifactsDir build parameter unless the build parameters already set one. Defaults to artifacts. .PARAMETER Environment The environment label, development or production. It is stored in the configuration and is not passed to the build. Defaults to development. .PARAMETER AdditionalParameters A hashtable of build parameters applied to every Invoke-Build. Parameters passed to a single Invoke-Build call through its buildArgs parameter win over these. Defaults to an empty hashtable. .EXAMPLE Set-BuildAgentConfig -DockerImage 'ghcr.io/the-running-dev/build-agent:2.0.0' -DockerHost 'tcp://host.docker.internal:2375' -WorkspacePath $PWD Points the build at the current directory, using a pinned image and the default daemon. The artifacts directory, environment and additional parameters take their defaults. .EXAMPLE Set-BuildAgentConfig -DockerImage 'example.com/team/agent:1.4.0' -DockerHost 'unix:///var/run/docker.sock' -WorkspacePath '/src/app' -ArtifactsDir './out' -Environment production -AdditionalParameters @{ imageTag = 'my-app' } Uses a custom image over the local Unix socket, writes Node artifacts to ./out, and applies the imageTag parameter to every build that follows. .EXAMPLE try { Set-BuildAgentConfig -DockerImage 'a/b:1' -DockerHost 'tcp://localhost:2375' -WorkspacePath 'C:\missing' } catch { exit $_.Exception.Data['ExitCode'] } Handles a workspace that does not exist: the error id is WorkspaceInvalid and the exit status to use is 3. .INPUTS None. Set-BuildAgentConfig does not accept pipeline input. .OUTPUTS None. The command writes a confirmation message to the host and returns nothing. .NOTES A workspace path that is absent or is not a directory raises a terminating error whose FullyQualifiedErrorId is WorkspaceInvalid and whose Exception.Data['ExitCode'] is 3. A DockerHost that does not match an accepted form, or an Environment other than development or production, is rejected by parameter validation with an ordinary parameter binding error that carries no code. Canonical contract (PowerShell module): PSModule.requirements.md, R-CONFIG-001 to R-CONFIG-004. The PowerShell Module page of the project documentation describes the same behavior. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$DockerImage, [Parameter(Mandatory = $true)] [ValidatePattern('^(tcp://[^\s:]+:\d+|unix:///[^\s]+|npipe:////\./pipe/[^\s]+)$')] [string]$DockerHost, [Parameter(Mandatory = $true)] [string]$WorkspacePath, [string]$ArtifactsDir = "artifacts", [ValidateSet('development', 'production')] [string]$Environment = 'development', [hashtable]$AdditionalParameters = @{} ) if (-not (Test-Path -LiteralPath $WorkspacePath -PathType Container)) { $PSCmdlet.ThrowTerminatingError((New-LauncherError -Code WorkspaceInvalid -ExitCode 3 -Message "Workspace path '$WorkspacePath' does not exist or is not a directory.")) } $script:BuildAgentConfig.DockerImage = $DockerImage $script:BuildAgentConfig.DockerHost = $DockerHost # Stored absolute: docker reads a bare relative name in -v as a named volume, not a path. $script:BuildAgentConfig.WorkspacePath = (Resolve-Path -LiteralPath $WorkspacePath).ProviderPath $script:BuildAgentConfig.ArtifactsDir = $ArtifactsDir $script:BuildAgentConfig.Environment = $Environment $script:BuildAgentConfig.Parameters = $AdditionalParameters Write-Host "[OK] Build Agent Configuration Updated." -ForegroundColor Green } # --- Private Helper Functions --- # A launcher error carries its stable code as FullyQualifiedErrorId and its process exit status in # Exception.Data['ExitCode'], so a caller can map it to a process exit without parsing text. function New-LauncherError { param( [string]$Code, [int]$ExitCode, [string]$Message ) $exception = New-Object System.InvalidOperationException($Message) $exception.Data['Code'] = $Code $exception.Data['ExitCode'] = $ExitCode return New-Object System.Management.Automation.ErrorRecord($exception, $Code, [System.Management.Automation.ErrorCategory]::InvalidOperation, $null) } # The only place `docker` is called. With -Capture the output is collected; without it the output # streams to the host (the build's own output) and only the exit status is returned. function Invoke-Docker { param( [string[]]$Arguments, [switch]$Capture ) if ($Capture) { $previous = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $output = & docker @Arguments 2>&1 | ForEach-Object { "$_" } } catch { $output = @("$_"); $global:LASTEXITCODE = 1 } finally { $ErrorActionPreference = $previous } return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = @($output) } } & docker @Arguments | Out-Host return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = @() } } # Makes the configured image present locally. Never substitutes another reference (I27). function Confirm-BuildAgentImage { param([string]$Image) if ((Invoke-Docker -Arguments @('image', 'inspect', $Image) -Capture).ExitCode -eq 0) { return } $pull = Invoke-Docker -Arguments @('pull', $Image) -Capture if ($pull.ExitCode -eq 0) { return } if ((Invoke-Docker -Arguments @('version', '--format', '{{.Server.Version}}') -Capture).ExitCode -ne 0) { return New-LauncherError -Code DockerUnavailable -ExitCode 5 -Message "Docker is not available: $($pull.Output -join ' ')" } return New-LauncherError -Code ImageUnavailable -ExitCode 5 -Message "Image '$Image' could not be obtained: $($pull.Output -join ' ')" } function Convert-ToKebabCase { param([string]$inputString) # -creplace: -replace ignores case, so [a-z][A-Z] would split every pair of letters. return ($inputString -creplace '([a-z])([A-Z])', '$1-$2').ToLower() } # --- Build Invocation --- function Get-BuildConfigName { param([string]$type) switch ($type) { 'docker' { return 'Docker' } 'node' { return 'Node' } 'node-in-docker' { return 'NodeInDocker' } 'node-template' { return 'NodeTemplate' } 'forge' { return 'Forge' } default { return $type } } } function Convert-HashtableToArgs { param([hashtable]$parameters) $result = @() foreach ($key in $parameters.Keys) { $value = $parameters[$key] if ($null -eq $value) { continue } $kebab = Convert-ToKebabCase -InputString $key if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) { foreach ($item in $value) { $result += "--$kebab" $result += "$item" } } else { $result += "--$kebab" $result += "$value" } } return $result } function Get-AllowedParametersForType { param([string]$type) $parametersJsonPath = Join-Path $PSScriptRoot "parameters.json" if (-not (Test-Path $parametersJsonPath)) { return @() } $buildConfigs = Get-Content $parametersJsonPath -Raw | ConvertFrom-Json $configName = Get-BuildConfigName -Type $type $config = $buildConfigs | Where-Object { $_.Name -eq $configName } | Select-Object -First 1 if (-not $config) { return @() } return $config.Parameters | ForEach-Object { $_.Name } } function Invoke-Build { <# .SYNOPSIS Runs one build in the build-agent container. .DESCRIPTION Runs `docker run --rm` against the image set by Set-BuildAgentConfig, with the workspace mounted at /workspace, DOCKER_HOST set, and `build <type>` followed by the build parameters. The command does the following, in order: 1. Merges the parameters from Set-BuildAgentConfig with buildArgs. buildArgs wins. 2. For the node, node-in-docker and node-template types, adds artifactsDir from the configuration unless you passed it and unless the configured value is blank. 3. With validateArgs, rejects any key that is not a known parameter of the build type. 4. Checks that the workspace is still a directory, then makes sure the image is present locally, pulling it if not. It never substitutes a different image, version or latest. 5. Runs the container. For a unix:// Docker host it also mounts the socket into the container. Each parameter key becomes a kebab-case option, so imageTag becomes --image-tag. A list value becomes the option repeated once per item, and a null value is left out. The build's own output streams to the console. Parameter values are never printed: the command line is logged with its arguments hidden. A project configuration file in the workspace is read by the build itself, and values passed here rank above it. .PARAMETER type The build type: docker, node, node-in-docker, node-template or forge. Any other value is rejected by parameter validation. Mandatory. .PARAMETER buildArgs A hashtable of build parameters for this run, keyed by parameter name. These win over the AdditionalParameters set through Set-BuildAgentConfig. Also accepted as -args. Defaults to an empty hashtable. .PARAMETER validateArgs Fails before anything runs when a merged parameter key is not a known parameter of the build type. The known names are read from parameters.json in the module directory. When that file is absent, or has no entry for the build type, nothing is rejected. The failure is an ordinary error that names the unknown keys and carries no code. .EXAMPLE Invoke-Build -type docker -buildArgs @{ imageTag = 'my-app'; registryUrl = 'ghcr.io/example' } Builds a Docker image from the configured workspace, passing --image-tag my-app and --registry-url ghcr.io/example to the build. .EXAMPLE Invoke-Build -type docker -buildArgs @{ tags = @('a', 'b') } -validateArgs Runs a Docker build with validation. The tags list becomes --tags a --tags b. With parameters.json present in the module directory, a key the docker build type does not know fails the call before docker is started. The buildArgs parameter can also be written as -args. .EXAMPLE try { Invoke-Build -type forge } catch { $code = $_.FullyQualifiedErrorId $status = $_.Exception.Data['ExitCode'] exit $status } Turns a launcher failure into a process exit. Match on the error id, not the message. .INPUTS None. Invoke-Build does not accept pipeline input. .OUTPUTS None. The command returns nothing on success. The container's own output streams to the host rather than to the pipeline. .NOTES A failure is a terminating error. Its FullyQualifiedErrorId is a stable code and its Exception.Data['ExitCode'] is the status a script should exit with: WorkspaceInvalid (3): the workspace path no longer exists or is not a directory. DockerUnavailable (5): the Docker daemon cannot be reached. ImageUnavailable (5): the configured image is not present locally and cannot be pulled. BuildFailed (the container's own status): the build exited non-zero. The status is carried unchanged, so 2 for invalid configuration passes through. The validateArgs failure is the exception: it is an ordinary error without a code. Success throws nothing. Canonical contract (PowerShell module): PSModule.requirements.md, R-INVOKE-001 to R-INVOKE-006 and R-VALIDATE-001 to R-VALIDATE-004. The PowerShell Module page of the project documentation describes the same behavior. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateSet('docker', 'node', 'node-in-docker', 'node-template', 'forge')] [string]$type, [Alias('args')] [hashtable]$buildArgs = @{}, [switch]$validateArgs ) Write-Host "Executing '$type' build..." $mergedArgs = @{} foreach ($key in $script:BuildAgentConfig.Parameters.Keys) { $mergedArgs[$key] = $script:BuildAgentConfig.Parameters[$key] } foreach ($key in $buildArgs.Keys) { $mergedArgs[$key] = $buildArgs[$key] } if (($type -in @('node', 'node-in-docker', 'node-template')) -and -not $mergedArgs.ContainsKey('artifactsDir') -and -not [string]::IsNullOrWhiteSpace($script:BuildAgentConfig.ArtifactsDir)) { $mergedArgs['artifactsDir'] = $script:BuildAgentConfig.ArtifactsDir } if ($validateArgs) { $allowed = Get-AllowedParametersForType -Type $type if ($allowed.Count -gt 0) { $unknown = $mergedArgs.Keys | Where-Object { $_ -notin $allowed } if ($unknown.Count -gt 0) { throw "Unknown parameter(s) for '$Type': $($unknown -join ', ')" } } } if (-not (Test-Path -LiteralPath $script:BuildAgentConfig.WorkspacePath -PathType Container)) { $PSCmdlet.ThrowTerminatingError((New-LauncherError -Code WorkspaceInvalid -ExitCode 3 -Message "Workspace path '$($script:BuildAgentConfig.WorkspacePath)' does not exist or is not a directory.")) } $imageError = Confirm-BuildAgentImage -Image $script:BuildAgentConfig.DockerImage if ($imageError) { $PSCmdlet.ThrowTerminatingError($imageError) } $argsList = @( "run", "--rm", "-v", "$($script:BuildAgentConfig.WorkspacePath):/workspace", "-w", "/workspace", "-e", "DOCKER_HOST=$($script:BuildAgentConfig.DockerHost)", "$($script:BuildAgentConfig.DockerImage)" ) if ($script:BuildAgentConfig.DockerHost -like 'unix:///*') { $socketPath = $script:BuildAgentConfig.DockerHost.Substring('unix://'.Length) $argsList = @( "run", "--rm", "-v", "$($script:BuildAgentConfig.WorkspacePath):/workspace", "-v", "$socketPath`:$socketPath", "-w", "/workspace", "-e", "DOCKER_HOST=$($script:BuildAgentConfig.DockerHost)", "$($script:BuildAgentConfig.DockerImage)" ) } $argsList += @( "build", "$type" ) $argsList += Convert-HashtableToArgs -Parameters $mergedArgs Write-Host "Executing: docker run ... build $type [arguments hidden for security]" $run = Invoke-Docker -Arguments $argsList if ($run.ExitCode -ne 0) { $PSCmdlet.ThrowTerminatingError((New-LauncherError -Code BuildFailed -ExitCode $run.ExitCode -Message "Docker invocation failed with exit code $($run.ExitCode)")) } } Export-ModuleMember -Function 'Set-BuildAgentConfig', 'Invoke-Build' -Variable 'BuildAgentConfig' |