#!/usr/bin/env bash set -euo pipefail umask 077 ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) source_env=${1:-} if [[ -z "$source_env" || ! -f "$source_env" ]]; then echo "Usage: $0 PRODUCTION_ENV_FILE" >&2 exit 2 fi for command in docker gzip jq sha256sum; do command -v "$command" >/dev/null 2>&1 || { echo "Required command is unavailable: $command" >&2 exit 2 } done if [[ -n "$(git -C "$ROOT" status --porcelain --untracked-files=no)" ]]; then echo "Refusing to build production images from a dirty tracked checkout." >&2 exit 2 fi commit=$(git -C "$ROOT" rev-parse --verify HEAD) short_commit=${commit:0:12} artifact_root=${WNH_PRODUCTION_RELEASE_ARTIFACT_ROOT:-"$ROOT/output/releases"} case "$artifact_root" in /*) ;; *) echo "WNH_PRODUCTION_RELEASE_ARTIFACT_ROOT must be an absolute path." >&2 exit 2 ;; esac mkdir -p "$artifact_root" artifact_root=$(realpath --canonicalize-existing "$artifact_root") release_dir="$artifact_root/$commit" archive="$release_dir/who_need_help-$commit-images-linux-amd64.tar.gz" checksum="$archive.sha256" manifest="$release_dir/who_need_help-$commit-images.manifest" mkdir -p "$release_dir" chmod 700 "$artifact_root" "$release_dir" build_env=$(mktemp "$release_dir/.production-image-build.XXXXXX") cleanup() { trap - EXIT HUP INT TERM rm -f "$build_env" } trap cleanup EXIT HUP INT TERM install -m 600 "$source_env" "$build_env" read_value() { local key=$1 awk -v key="$key" ' index($0, key "=") == 1 { print substr($0, length(key) + 2) found = 1 exit } END { if (!found) exit 1 } ' "$build_env" } replace_value() { local key=$1 local value=$2 local temporary temporary=$(mktemp "$release_dir/.production-image-env.XXXXXX") chmod 600 "$temporary" awk -v key="$key" -v value="$value" ' index($0, key "=") == 1 { print key "=" value; found = 1; next } { print } END { if (!found) exit 1 } ' "$build_env" >"$temporary" mv "$temporary" "$build_env" chmod 600 "$build_env" } [[ "$(read_value DEPLOYMENT_ENV)" == production ]] || { echo "The image build input is not a production environment." >&2 exit 2 } [[ "$(read_value DATABASE_MODE)" == external ]] || { echo "The verified production image workflow expects DATABASE_MODE=external." >&2 exit 2 } replace_value APP_IMAGE "who-need-help:production-$short_commit" replace_value SOCKET_PROXY_IMAGE \ "who-need-help:socket-proxy-production-$short_commit" replace_value POSTGIS_IMAGE "who-need-help:postgis-production-$short_commit" topology=$(read_value APP_TOPOLOGY) case "$topology" in compact) build_services=(migrate) ;; split) build_services=(docker-api-proxy proxy migrate) ;; *) echo "APP_TOPOLOGY must be compact or split." >&2 exit 2 ;; esac app_image=$(read_value APP_IMAGE) images=("$app_image") if [[ "$topology" == split ]]; then socket_proxy_image=$(read_value SOCKET_PROXY_IMAGE) proxy_image=$( "$ROOT/scripts/compose.sh" "$build_env" config --format json | jq -er '.services.proxy.image' ) images+=("$socket_proxy_image" "$proxy_image") fi if [[ -e "$archive" || -e "$checksum" || -e "$manifest" ]]; then [[ -f "$archive" && -f "$checksum" && -f "$manifest" ]] || { echo "The production image package is incomplete; refusing to overwrite it." >&2 exit 2 } ( cd "$release_dir" sha256sum --check "$(basename -- "$checksum")" >/dev/null ) echo "Production image package already exists; verifying all metadata." else "$ROOT/scripts/compose.sh" "$build_env" build "${build_services[@]}" manifest_tmp=$(mktemp "$release_dir/.production-images-manifest.XXXXXX") archive_tmp=$(mktemp "$release_dir/.production-images-archive.XXXXXX") trap 'rm -f "$build_env" "$manifest_tmp" "$archive_tmp"' EXIT HUP INT TERM { printf 'format=1\n' printf 'commit=%s\n' "$commit" printf 'platform=linux/amd64\n' printf 'topology=%s\n' "$topology" printf 'image_count=%s\n' "${#images[@]}" for image in "${images[@]}"; do platform=$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$image") [[ "$platform" == linux/amd64 ]] || { echo "Production image has an unexpected platform: $image ($platform)" >&2 exit 2 } image_id=$(docker image inspect --format '{{.Id}}' "$image") printf 'image=%s|%s\n' "$image" "$image_id" done } >"$manifest_tmp" docker save "${images[@]}" | gzip -n -9 >"$archive_tmp" mv "$archive_tmp" "$archive" mv "$manifest_tmp" "$manifest" chmod 600 "$archive" "$manifest" hash=$(sha256sum "$archive" | awk '{print $1}') printf '%s %s\n' "$hash" "$(basename -- "$archive")" >"$checksum" chmod 600 "$checksum" fi ( cd "$release_dir" sha256sum --check "$(basename -- "$checksum")" >/dev/null ) gzip -t "$archive" archive_hash=$(sha256sum "$archive" | awk '{print $1}') expected_checksum="$archive_hash $(basename -- "$archive")" if [[ "$(cat -- "$checksum")" != "$expected_checksum" ]]; then echo "Production image checksum metadata does not name the exact archive." >&2 exit 2 fi manifest_value() { local key=$1 awk -F= -v key="$key" ' $1 == key { count += 1; value = substr($0, length(key) + 2) } END { if (count != 1) exit 1 print value } ' "$manifest" } [[ "$(manifest_value format)" == 1 ]] || { echo "Production image manifest format is unsupported." >&2 exit 2 } [[ "$(manifest_value commit)" == "$commit" ]] || { echo "Production image manifest commit does not match the current commit." >&2 exit 2 } [[ "$(manifest_value platform)" == linux/amd64 ]] || { echo "Production image manifest platform is not linux/amd64." >&2 exit 2 } [[ "$(manifest_value topology)" == "$topology" ]] || { echo "Production image manifest topology does not match the environment." >&2 exit 2 } [[ "$(manifest_value image_count)" == "${#images[@]}" ]] || { echo "Production image manifest count does not match the required images." >&2 exit 2 } test "$(grep -c '^image=' "$manifest")" = "${#images[@]}" for image in "${images[@]}"; do awk -F'|' -v image="$image" ' $1 == "image=" image && $2 ~ /^sha256:[0-9a-f]+$/ { found = 1 } END { if (!found) exit 1 } ' "$manifest" done cleanup printf 'Production image archive: %s\n' "$archive" printf 'Image archive checksum: %s\n' "$checksum" printf 'Image manifest: %s\n' "$manifest"