#!/usr/bin/env pwsh <# .SYNOPSIS Build hardened Docker images for Stella Ops services using the shared matrix. .DESCRIPTION The default path publishes .NET services locally and builds hardened runtime images from small temporary contexts so scratch setup does not keep streaming the full monorepo into Docker for every backend image. .PARAMETER Registry Docker image registry prefix. Default: stellaops .PARAMETER TagSuffix Tag suffix for built images. Default: dev .PARAMETER SdkImage Reserved for legacy repo-context builds. Default: mcr.microsoft.com/dotnet/sdk:10.0-noble .PARAMETER RuntimeImage .NET runtime base image. Default: mcr.microsoft.com/dotnet/aspnet:10.0-noble .PARAMETER Services Optional service filter. Only listed services are rebuilt. .PARAMETER PublishNoRestore Skip restore during local dotnet publish when a prior solution build already ran. .PARAMETER UseLegacyRepoContext Fall back to repo-root docker builds for backend services. #> [CmdletBinding()] param( [string]$Registry, [string]$TagSuffix, [string]$SdkImage, [string]$RuntimeImage, [string[]]$Services, [switch]$PublishNoRestore, [switch]$UseLegacyRepoContext ) $ErrorActionPreference = 'Continue' $previousNativeErrorPreference = $null if ($PSVersionTable.PSVersion.Major -ge 7) { $previousNativeErrorPreference = $global:PSNativeCommandUseErrorActionPreference $global:PSNativeCommandUseErrorActionPreference = $false } if ([string]::IsNullOrWhiteSpace($Registry)) { $Registry = if ([string]::IsNullOrWhiteSpace($env:REGISTRY)) { 'stellaops' } else { $env:REGISTRY } } if ([string]::IsNullOrWhiteSpace($TagSuffix)) { $TagSuffix = if ([string]::IsNullOrWhiteSpace($env:TAG_SUFFIX)) { 'dev' } else { $env:TAG_SUFFIX } } if ([string]::IsNullOrWhiteSpace($SdkImage)) { $SdkImage = if ([string]::IsNullOrWhiteSpace($env:SDK_IMAGE)) { 'mcr.microsoft.com/dotnet/sdk:10.0-noble' } else { $env:SDK_IMAGE } } if ([string]::IsNullOrWhiteSpace($RuntimeImage)) { $RuntimeImage = if ([string]::IsNullOrWhiteSpace($env:RUNTIME_IMAGE)) { 'mcr.microsoft.com/dotnet/aspnet:10.0-noble' } else { $env:RUNTIME_IMAGE } } if ($Registry.StartsWith('-')) { Write-Error "Registry value '$Registry' is invalid. Invoke build-all.ps1 with named parameters so switches are not passed positionally." exit 1 } $Root = git rev-parse --show-toplevel 2>$null if (-not $Root) { Write-Error 'Not inside a git repository.' exit 1 } $Root = $Root.Trim() $MatrixPath = Join-Path $Root 'devops/docker/services-matrix.env' if (-not (Test-Path $MatrixPath)) { Write-Error "Matrix file not found: $MatrixPath" exit 1 } $runtimeDockerfile = Join-Path $Root 'devops/docker/Dockerfile.hardened.runtime' $healthcheckScript = Join-Path $Root 'devops/docker/healthcheck.sh' $fastContextRoot = Join-Path ([System.IO.Path]::GetTempPath()) 'stellaops-fast-images' $serviceFilter = @{} foreach ($serviceName in ($Services | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })) { $serviceFilter[$serviceName.Trim().ToLowerInvariant()] = $true } Write-Host "Building services from $MatrixPath -> ${Registry}/:${TagSuffix}" -ForegroundColor Cyan if ($serviceFilter.Count -gt 0) { Write-Host "Service filter: $($serviceFilter.Keys -join ', ')" -ForegroundColor Cyan } $succeeded = @() $failed = @() function Invoke-DockerBuild([string[]]$Arguments) { & docker @Arguments 2>&1 | ForEach-Object { $text = if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { "$_" } Write-Host $text } return $LASTEXITCODE } function Invoke-NativeCommand([string]$Command, [string[]]$Arguments) { & $Command @Arguments 2>&1 | ForEach-Object { $text = if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { "$_" } Write-Host $text } return $LASTEXITCODE } function Should-BuildService([string]$ServiceName) { if ($serviceFilter.Count -eq 0) { return $true } return $serviceFilter.ContainsKey($ServiceName.ToLowerInvariant()) } function Remove-BuildContext([string]$ContextPath) { if (Test-Path $ContextPath) { Remove-Item -Path $ContextPath -Recurse -Force -ErrorAction SilentlyContinue } } function New-PublishedBuildContext([string]$Service, [string]$Project) { if (-not (Test-Path $runtimeDockerfile)) { throw "Runtime Dockerfile not found: $runtimeDockerfile" } if (-not (Test-Path $healthcheckScript)) { throw "Healthcheck script not found: $healthcheckScript" } $serviceRoot = Join-Path $fastContextRoot $Service $appDir = Join-Path $serviceRoot 'app' Remove-BuildContext $serviceRoot New-Item -ItemType Directory -Path $appDir -Force | Out-Null $publishArguments = @( 'publish', (Join-Path $Root $Project), '-c', 'Release', '-o', $appDir, '/p:UseAppHost=false', '/p:PublishTrimmed=false', '--nologo' ) if ($PublishNoRestore) { $publishArguments += '--no-restore' } $publishExitCode = Invoke-NativeCommand 'dotnet' $publishArguments if ($publishExitCode -ne 0) { Remove-BuildContext $serviceRoot throw "dotnet publish failed for $Service" } Copy-Item -Path $runtimeDockerfile -Destination (Join-Path $serviceRoot 'Dockerfile') -Force Copy-Item -Path $healthcheckScript -Destination (Join-Path $serviceRoot 'healthcheck.sh') -Force return $serviceRoot } foreach ($line in Get-Content $MatrixPath) { $line = $line.Trim() if (-not $line -or $line.StartsWith('#')) { continue } $parts = $line -split '\|' if ($parts.Count -lt 5) { continue } $service = $parts[0] $dockerfile = $parts[1] $project = $parts[2] $binary = $parts[3] $port = $parts[4] if (-not (Should-BuildService $service)) { continue } $image = "${Registry}/${service}:${TagSuffix}" $dfPath = Join-Path $Root $dockerfile if (-not (Test-Path $dfPath)) { Write-Warning "Skipping ${service}: dockerfile missing ($dfPath)" continue } if ($dockerfile -like '*Dockerfile.console*') { Write-Host "[console] $service -> $image" -ForegroundColor Yellow $buildExitCode = Invoke-DockerBuild @( 'build', '-f', $dfPath, $Root, '--build-arg', "APP_DIR=$project", '--build-arg', "APP_PORT=$port", '-t', $image ) } elseif (-not $UseLegacyRepoContext -and $dockerfile -like '*Dockerfile.hardened.template*') { Write-Host "[service fast] $service -> $image" -ForegroundColor Green $contextPath = $null try { $contextPath = New-PublishedBuildContext -Service $service -Project $project $buildExitCode = Invoke-DockerBuild @( 'build', '-f', (Join-Path $contextPath 'Dockerfile'), $contextPath, '--build-arg', "RUNTIME_IMAGE=$RuntimeImage", '--build-arg', "APP_BINARY=$binary", '--build-arg', "APP_PORT=$port", '-t', $image ) } catch { Write-Host $_.Exception.Message -ForegroundColor Red $buildExitCode = 1 } finally { if ($contextPath) { Remove-BuildContext $contextPath } } } else { Write-Host "[service] $service -> $image" -ForegroundColor Green $buildExitCode = Invoke-DockerBuild @( 'build', '-f', $dfPath, $Root, '--build-arg', "SDK_IMAGE=$SdkImage", '--build-arg', "RUNTIME_IMAGE=$RuntimeImage", '--build-arg', "APP_PROJECT=$project", '--build-arg', "APP_BINARY=$binary", '--build-arg', "APP_PORT=$port", '-t', $image ) } if ($buildExitCode -eq 0) { $succeeded += $service } else { $failed += $service Write-Host "FAILED: $service" -ForegroundColor Red } } Write-Host '' Write-Host '=== BUILD RESULTS ===' -ForegroundColor Cyan Write-Host "Succeeded ($($succeeded.Count)): $($succeeded -join ', ')" -ForegroundColor Green Write-Host "Failed ($($failed.Count)): $($failed -join ', ')" -ForegroundColor $(if ($failed.Count -gt 0) { 'Red' } else { 'Green' }) Write-Host '' if ($failed.Count -gt 0) { Write-Error 'Some builds failed. Fix the issues and re-run.' exit 1 } Write-Host 'Build complete. Remember to enforce readOnlyRootFilesystem at deploy time and run sbom_attest.sh (DOCKER-44-002).' -ForegroundColor Cyan if ($PSVersionTable.PSVersion.Major -ge 7) { $global:PSNativeCommandUseErrorActionPreference = $previousNativeErrorPreference }