Speed up scratch image builds with publish-first contexts
This commit is contained in:
44
devops/docker/Dockerfile.hardened.runtime
Normal file
44
devops/docker/Dockerfile.hardened.runtime
Normal file
@@ -0,0 +1,44 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# Runtime-only hardened image for publish-first local builds.
|
||||
|
||||
ARG RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0-noble
|
||||
ARG APP_BINARY=StellaOps.Service
|
||||
ARG APP_USER=stella
|
||||
ARG APP_UID=10001
|
||||
ARG APP_GID=10001
|
||||
ARG APP_PORT=8080
|
||||
|
||||
FROM ${RUNTIME_IMAGE} AS runtime
|
||||
ARG APP_BINARY=StellaOps.Service
|
||||
ARG APP_USER=stella
|
||||
ARG APP_UID=10001
|
||||
ARG APP_GID=10001
|
||||
ARG APP_PORT=8080
|
||||
|
||||
RUN groupadd -r -g ${APP_GID} ${APP_USER} && \
|
||||
useradd -r -u ${APP_UID} -g ${APP_GID} -d /var/lib/${APP_USER} ${APP_USER} && \
|
||||
mkdir -p /app /var/lib/${APP_USER} /var/run/${APP_USER} /tmp && \
|
||||
chown -R ${APP_UID}:${APP_GID} /app /var/lib/${APP_USER} /var/run/${APP_USER} /tmp
|
||||
|
||||
WORKDIR /app
|
||||
COPY --chown=${APP_UID}:${APP_GID} app/ ./
|
||||
COPY --chown=${APP_UID}:${APP_GID} healthcheck.sh /usr/local/bin/healthcheck.sh
|
||||
|
||||
ENV ASPNETCORE_URLS=http://+:${APP_PORT} \
|
||||
DOTNET_EnableDiagnostics=0 \
|
||||
COMPlus_EnableDiagnostics=0 \
|
||||
APP_BINARY=${APP_BINARY}
|
||||
|
||||
RUN chmod 500 /app && \
|
||||
chmod +x /usr/local/bin/healthcheck.sh && \
|
||||
find /app -maxdepth 1 -type f -name '*.dll' -exec chmod 400 {} \; && \
|
||||
find /app -maxdepth 1 -type f -name '*.json' -exec chmod 400 {} \; && \
|
||||
find /app -maxdepth 1 -type f -name '*.pdb' -exec chmod 400 {} \; && \
|
||||
find /app -maxdepth 1 -type d -exec chmod 500 {} \;
|
||||
|
||||
USER ${APP_UID}:${APP_GID}
|
||||
EXPOSE ${APP_PORT}
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD /usr/local/bin/healthcheck.sh
|
||||
|
||||
ENTRYPOINT ["sh","-c","exec dotnet ./\"$APP_BINARY\".dll"]
|
||||
@@ -1,28 +1,43 @@
|
||||
#!/usr/bin/env pwsh
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Build hardened Docker images for all Stella Ops services using the shared template/matrix.
|
||||
Build hardened Docker images for Stella Ops services using the shared matrix.
|
||||
.DESCRIPTION
|
||||
PowerShell port of build-all.sh. Reads services-matrix.env (pipe-delimited) and builds
|
||||
each service image using Dockerfile.hardened.template (or Dockerfile.console for Angular).
|
||||
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
|
||||
.NET SDK base image. Default: mcr.microsoft.com/dotnet/sdk:10.0-noble
|
||||
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]$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 }
|
||||
@@ -53,11 +68,105 @@ if (-not (Test-Path $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 }
|
||||
@@ -65,11 +174,15 @@ foreach ($line in Get-Content $MatrixPath) {
|
||||
$parts = $line -split '\|'
|
||||
if ($parts.Count -lt 5) { continue }
|
||||
|
||||
$service = $parts[0]
|
||||
$service = $parts[0]
|
||||
$dockerfile = $parts[1]
|
||||
$project = $parts[2]
|
||||
$binary = $parts[3]
|
||||
$port = $parts[4]
|
||||
$project = $parts[2]
|
||||
$binary = $parts[3]
|
||||
$port = $parts[4]
|
||||
|
||||
if (-not (Should-BuildService $service)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$image = "${Registry}/${service}:${TagSuffix}"
|
||||
$dfPath = Join-Path $Root $dockerfile
|
||||
@@ -81,25 +194,57 @@ foreach ($line in Get-Content $MatrixPath) {
|
||||
|
||||
if ($dockerfile -like '*Dockerfile.console*') {
|
||||
Write-Host "[console] $service -> $image" -ForegroundColor Yellow
|
||||
docker build `
|
||||
-f $dfPath $Root `
|
||||
--build-arg "APP_DIR=$project" `
|
||||
--build-arg "APP_PORT=$port" `
|
||||
-t $image
|
||||
$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
|
||||
docker 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
|
||||
$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 ($LASTEXITCODE -eq 0) {
|
||||
if ($buildExitCode -eq 0) {
|
||||
$succeeded += $service
|
||||
}
|
||||
else {
|
||||
@@ -120,3 +265,7 @@ if ($failed.Count -gt 0) {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build hardened images for the core services using the shared template/matrix (DOCKER-44-001)
|
||||
# Build hardened images for the core services using the shared template/matrix.
|
||||
# The default path publishes .NET services locally and builds runtime-only
|
||||
# images from small temporary contexts to avoid repeatedly sending the full
|
||||
# monorepo into Docker.
|
||||
set -uo pipefail
|
||||
|
||||
FAILED=()
|
||||
SUCCEEDED=()
|
||||
|
||||
@@ -10,6 +14,12 @@ REGISTRY=${REGISTRY:-"stellaops"}
|
||||
TAG_SUFFIX=${TAG_SUFFIX:-"dev"}
|
||||
SDK_IMAGE=${SDK_IMAGE:-"mcr.microsoft.com/dotnet/sdk:10.0-noble"}
|
||||
RUNTIME_IMAGE=${RUNTIME_IMAGE:-"mcr.microsoft.com/dotnet/aspnet:10.0-noble"}
|
||||
USE_LEGACY_REPO_CONTEXT=${USE_LEGACY_REPO_CONTEXT:-"false"}
|
||||
PUBLISH_NO_RESTORE=${PUBLISH_NO_RESTORE:-"false"}
|
||||
SERVICES=${SERVICES:-""}
|
||||
FAST_CONTEXT_ROOT=${FAST_CONTEXT_ROOT:-"${TMPDIR:-/tmp}/stellaops-fast-images"}
|
||||
RUNTIME_DOCKERFILE="${ROOT}/devops/docker/Dockerfile.hardened.runtime"
|
||||
HEALTHCHECK_SCRIPT="${ROOT}/devops/docker/healthcheck.sh"
|
||||
|
||||
if [[ ! -f "${MATRIX}" ]]; then
|
||||
echo "matrix file not found: ${MATRIX}" >&2
|
||||
@@ -17,9 +27,75 @@ if [[ ! -f "${MATRIX}" ]]; then
|
||||
fi
|
||||
|
||||
echo "Building services from ${MATRIX} -> ${REGISTRY}/<service>:${TAG_SUFFIX}" >&2
|
||||
if [[ -n "${SERVICES}" ]]; then
|
||||
echo "Service filter: ${SERVICES}" >&2
|
||||
fi
|
||||
|
||||
cleanup_context() {
|
||||
local context_dir="${1:-}"
|
||||
[[ -n "${context_dir}" && -d "${context_dir}" ]] && rm -rf "${context_dir}"
|
||||
}
|
||||
|
||||
should_build_service() {
|
||||
local service="$1"
|
||||
[[ -z "${SERVICES}" ]] && return 0
|
||||
|
||||
IFS=',' read -r -a requested <<< "${SERVICES}"
|
||||
for candidate in "${requested[@]}"; do
|
||||
local trimmed="${candidate// /}"
|
||||
[[ "${trimmed}" == "${service}" ]] && return 0
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
build_published_service_image() {
|
||||
local service="$1"
|
||||
local project="$2"
|
||||
local binary="$3"
|
||||
local port="$4"
|
||||
local image="$5"
|
||||
local context_dir="${FAST_CONTEXT_ROOT}/${service}"
|
||||
|
||||
cleanup_context "${context_dir}"
|
||||
mkdir -p "${context_dir}/app"
|
||||
|
||||
local publish_args=(
|
||||
publish "${ROOT}/${project}"
|
||||
-c Release
|
||||
-o "${context_dir}/app"
|
||||
/p:UseAppHost=false
|
||||
/p:PublishTrimmed=false
|
||||
--nologo
|
||||
)
|
||||
|
||||
if [[ "${PUBLISH_NO_RESTORE}" == "true" ]]; then
|
||||
publish_args+=(--no-restore)
|
||||
fi
|
||||
|
||||
dotnet "${publish_args[@]}" || {
|
||||
cleanup_context "${context_dir}"
|
||||
return 1
|
||||
}
|
||||
|
||||
cp "${RUNTIME_DOCKERFILE}" "${context_dir}/Dockerfile"
|
||||
cp "${HEALTHCHECK_SCRIPT}" "${context_dir}/healthcheck.sh"
|
||||
|
||||
docker build \
|
||||
-f "${context_dir}/Dockerfile" "${context_dir}" \
|
||||
--build-arg "RUNTIME_IMAGE=${RUNTIME_IMAGE}" \
|
||||
--build-arg "APP_BINARY=${binary}" \
|
||||
--build-arg "APP_PORT=${port}" \
|
||||
-t "${image}"
|
||||
local build_status=$?
|
||||
cleanup_context "${context_dir}"
|
||||
return ${build_status}
|
||||
}
|
||||
|
||||
while IFS='|' read -r service dockerfile project binary port; do
|
||||
[[ -z "${service}" || "${service}" =~ ^# ]] && continue
|
||||
should_build_service "${service}" || continue
|
||||
|
||||
image="${REGISTRY}/${service}:${TAG_SUFFIX}"
|
||||
df_path="${ROOT}/${dockerfile}"
|
||||
if [[ ! -f "${df_path}" ]]; then
|
||||
@@ -28,13 +104,15 @@ while IFS='|' read -r service dockerfile project binary port; do
|
||||
fi
|
||||
|
||||
if [[ "${dockerfile}" == *"Dockerfile.console"* ]]; then
|
||||
# Angular console build uses its dedicated Dockerfile
|
||||
echo "[console] ${service} -> ${image}" >&2
|
||||
docker build \
|
||||
-f "${df_path}" "${ROOT}" \
|
||||
--build-arg APP_DIR="${project}" \
|
||||
--build-arg APP_PORT="${port}" \
|
||||
-t "${image}"
|
||||
elif [[ "${USE_LEGACY_REPO_CONTEXT}" != "true" && "${dockerfile}" == *"Dockerfile.hardened.template"* ]]; then
|
||||
echo "[service fast] ${service} -> ${image}" >&2
|
||||
build_published_service_image "${service}" "${project}" "${binary}" "${port}" "${image}"
|
||||
else
|
||||
echo "[service] ${service} -> ${image}" >&2
|
||||
docker build \
|
||||
@@ -53,7 +131,6 @@ while IFS='|' read -r service dockerfile project binary port; do
|
||||
FAILED+=("${service}")
|
||||
echo "FAILED: ${service}" >&2
|
||||
fi
|
||||
|
||||
done < "${MATRIX}"
|
||||
|
||||
echo "" >&2
|
||||
@@ -65,4 +142,5 @@ if [[ ${#FAILED[@]} -gt 0 ]]; then
|
||||
echo "Some builds failed. Fix the issues and re-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Build complete. Remember to enforce readOnlyRootFilesystem at deploy time and run sbom_attest.sh (DOCKER-44-002)." >&2
|
||||
|
||||
Reference in New Issue
Block a user