277 lines
8.7 KiB
PowerShell
277 lines
8.7 KiB
PowerShell
#!/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}/<service>:${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
|
|
}
|