#!/usr/bin/env bash set -Eeuo pipefail deploy_root="${1:?deployment root is required}" release_id="${2:?release id is required}" image="${3:?runtime image is required}" sequence="${4:?deployment sequence is required}" incoming_compose="${5:?incoming Compose file is required}" if [[ "$deploy_root" != /* || "$deploy_root" == "/" ]]; then echo "DEPLOY_PATH must be an absolute directory other than /." >&2 exit 2 fi if [[ ! "$release_id" =~ ^[0-9a-f]{7,64}$ ]]; then echo "The release id must be a Git commit SHA." >&2 exit 2 fi if [[ "$image" != "amiirkhl-portfolio:$release_id" ]]; then echo "The runtime image must be tagged with the release id." >&2 exit 2 fi if [[ ! "$sequence" =~ ^[1-9][0-9]*$ ]]; then echo "The deployment sequence must be a positive integer." >&2 exit 2 fi if [[ ! -f "$incoming_compose" ]]; then echo "Incoming Compose file not found: $incoming_compose" >&2 exit 2 fi if ! command -v flock >/dev/null 2>&1; then echo "flock is required to serialize deployments." >&2 exit 2 fi releases_dir="$deploy_root/releases" release_dir="$releases_dir/$release_id" current_link="$deploy_root/current" staging_dir="$releases_dir/.incoming-$release_id-$$" cleanup() { rm -f -- "$incoming_compose" if [[ -d "$staging_dir" ]]; then rm -rf -- "$staging_dir" fi } trap cleanup EXIT mkdir -p -- "$releases_dir" exec 9>"$deploy_root/.deploy.lock" flock -x 9 sync_checkout() { local branch remote_release if [[ ! -d "$deploy_root/.git" ]]; then echo "Deployment root is not a Git checkout: $deploy_root" >&2 return 1 fi if ! git -C "$deploy_root" diff --quiet --ignore-submodules -- \ || ! git -C "$deploy_root" diff --cached --quiet --ignore-submodules --; then echo "Refusing to update a checkout with tracked changes: $deploy_root" >&2 return 1 fi branch="$(git -C "$deploy_root" symbolic-ref --quiet --short HEAD || true)" if [[ "$branch" != "main" ]]; then echo "Deployment checkout must be on main, not ${branch:-detached HEAD}." >&2 return 1 fi git -C "$deploy_root" fetch --quiet \ origin "+refs/heads/main:refs/remotes/origin/main" remote_release="$(git -C "$deploy_root" rev-parse refs/remotes/origin/main)" if [[ "$remote_release" != "$release_id" ]]; then echo "origin/main is $remote_release, expected deployed release $release_id." >&2 return 1 fi git -C "$deploy_root" merge --ff-only --quiet refs/remotes/origin/main if [[ "$(git -C "$deploy_root" rev-parse HEAD)" != "$release_id" ]]; then echo "Checkout did not advance to deployed release $release_id." >&2 return 1 fi } if ! docker image inspect "$image" >/dev/null 2>&1; then echo "Verified runtime image is missing from the production Docker daemon: $image" >&2 exit 1 fi previous_release="" if [[ -L "$current_link" ]]; then previous_release="$(readlink -f -- "$current_link" || true)" fi if [[ -n "$previous_release" && -f "$previous_release/release.env" ]]; then current_sequence="$(sed -n 's/^DEPLOY_SEQUENCE=//p' "$previous_release/release.env")" current_release="$(basename "$previous_release")" if [[ "$current_release" == "$release_id" ]]; then sync_checkout echo "Release and server checkout are already active: $release_id" exit 0 fi if [[ "$current_sequence" =~ ^[1-9][0-9]*$ ]] && (( sequence <= current_sequence )); then echo "Refusing stale deployment sequence $sequence; current sequence is $current_sequence." >&2 exit 1 fi fi if [[ ! -d "$release_dir" ]]; then mkdir -- "$staging_dir" install -m 600 "$incoming_compose" "$staging_dir/compose.yaml" { printf 'PORTFOLIO_IMAGE=%s\n' "$image" printf 'DEPLOY_SEQUENCE=%s\n' "$sequence" } > "$staging_dir/release.env" mv -- "$staging_dir" "$release_dir" fi if [[ ! -f "$release_dir/compose.yaml" || ! -f "$release_dir/release.env" ]]; then echo "Release metadata is incomplete: $release_dir" >&2 exit 2 fi compose() { local directory="$1" shift docker compose \ --project-name portfolio \ --file "$directory/compose.yaml" \ --env-file "$directory/release.env" \ "$@" } wait_until_healthy() { local directory="$1" local container_id status container_id="$(compose "$directory" ps -q portfolio-app)" if [[ -z "$container_id" ]]; then echo "The portfolio-app container was not created." >&2 return 1 fi for _ in {1..30}; do status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")" case "$status" in healthy) return 0 ;; unhealthy|exited|dead) echo "Container entered state: $status" >&2 return 1 ;; esac sleep 2 done echo "Container did not become healthy within 60 seconds." >&2 return 1 } activate() { local directory="$1" local release_image release_image="$(sed -n 's/^PORTFOLIO_IMAGE=//p' "$directory/release.env")" if [[ -z "$release_image" ]] || ! docker image inspect "$release_image" >/dev/null 2>&1; then echo "Release image is unavailable: ${release_image:-}" >&2 return 1 fi compose "$directory" config --quiet compose "$directory" up --detach --no-build --pull never --remove-orphans wait_until_healthy "$directory" } if activate "$release_dir"; then new_link="$deploy_root/.current-$release_id-$$" ln -s -- "$release_dir" "$new_link" mv -Tf -- "$new_link" "$current_link" if ! sync_checkout; then echo "Deployment is healthy, but the server checkout could not be updated." >&2 exit 1 fi echo "Deployment succeeded: $release_id ($image)" exit 0 fi echo "Deployment failed; attempting rollback." >&2 if [[ -n "$previous_release" && -f "$previous_release/compose.yaml" && -f "$previous_release/release.env" ]]; then activate "$previous_release" echo "Rollback succeeded: $(basename "$previous_release")" >&2 else echo "No image-based previous release is available for automatic rollback." >&2 fi exit 1