#!/usr/bin/env bash set -Eeuo pipefail umask 027 # Install as /usr/local/bin/forgeflow-deploy, owned by root and not writable by # the Gitea runner. Targets are read from the root-owned data file below. readonly CONFIG_FILE="/etc/forgeflow/targets.conf" readonly REPOSITORY="${1:-}" readonly ENVIRONMENT="${2:-}" readonly SHA="${3:-}" readonly REQUEST_ID="${4:-manual-$(date +%s)}" fail_usage() { echo "Usage: forgeflow-deploy [request-id]" >&2 exit 64 } [[ "$REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || fail_usage [[ "$ENVIRONMENT" =~ ^[A-Za-z0-9._-]+$ ]] || fail_usage [[ "$SHA" =~ ^[0-9a-fA-F]{40,64}$ ]] || fail_usage [[ "$REQUEST_ID" =~ ^[A-Za-z0-9._:-]{1,100}$ ]] || fail_usage [[ -f "$CONFIG_FILE" ]] || { echo "Missing target configuration: $CONFIG_FILE" >&2; exit 78; } # The target file is security-sensitive because it controls root-executed paths. config_owner="$(stat -c '%U' "$CONFIG_FILE")" config_mode="$(stat -c '%a' "$CONFIG_FILE")" [[ "$config_owner" == "root" ]] || { echo "Target configuration must be owned by root" >&2; exit 78; } # Reject group/other write bits. GNU stat returns an octal string such as 640. (( (8#$config_mode & 8#022) == 0 )) || { echo "Target configuration may not be group/other writable" >&2; exit 78; } APP_DIR="" BRANCH="" COMPOSE_FILE="" HEALTHCHECK_URL="" STATUS_FILE="" while IFS='|' read -r config_repository config_environment config_app_dir config_branch config_compose config_health config_status extra; do [[ -z "${config_repository// }" || "$config_repository" == \#* ]] && continue [[ -z "${extra:-}" ]] || { echo "Invalid extra field in $CONFIG_FILE" >&2; exit 78; } if [[ "$config_repository" == "$REPOSITORY" && "$config_environment" == "$ENVIRONMENT" ]]; then APP_DIR="$config_app_dir" BRANCH="$config_branch" COMPOSE_FILE="$config_compose" HEALTHCHECK_URL="$config_health" STATUS_FILE="$config_status" break fi done < "$CONFIG_FILE" [[ -n "$APP_DIR" ]] || { echo "Repository/environment is not allowlisted" >&2; exit 64; } [[ "$APP_DIR" == /* && "$COMPOSE_FILE" == /* && "$STATUS_FILE" == /var/lib/forgeflow-status/* ]] || { echo "Target configuration contains an unsafe path" >&2 exit 78 } [[ "$BRANCH" =~ ^[A-Za-z0-9._/-]+$ && "$BRANCH" != *..* ]] || { echo "Unsafe branch in target configuration" >&2; exit 78; } [[ -d "$APP_DIR/.git" ]] || { echo "Application directory is not a Git working tree: $APP_DIR" >&2; exit 72; } [[ -f "$COMPOSE_FILE" ]] || { echo "Compose file does not exist: $COMPOSE_FILE" >&2; exit 72; } install -d -o root -g root -m 0755 "$(dirname "$STATUS_FILE")" json_string() { # Inputs accepted by this script are deliberately restricted to characters # that do not need JSON escaping. This guard prevents accidental expansion. [[ "$1" =~ ^[A-Za-z0-9._:/-]*$ ]] || return 1 printf '%s' "$1" } write_status() { local health="$1" local live_sha="$2" local previous_sha="$3" local exit_code="${4:-0}" local deployed_at temporary deployed_at="$(date --utc +%Y-%m-%dT%H:%M:%SZ)" temporary="${STATUS_FILE}.${$}.tmp" json_string "$health" >/dev/null json_string "$REQUEST_ID" >/dev/null [[ "$live_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || { echo "Invalid live SHA for status output" >&2; return 1; } [[ "$previous_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || { echo "Invalid previous SHA for status output" >&2; return 1; } cat > "$temporary" <"/run/lock/forgeflow-${REPOSITORY//\//-}-${ENVIRONMENT}.lock" flock -n 9 || { echo "Another deployment is already running" >&2; exit 75; } current_sha="$(git -C "$APP_DIR" rev-parse HEAD)" previous_sha="$(cat "$APP_DIR/.forgeflow-live-sha" 2>/dev/null || printf '%s' "$current_sha")" [[ "$previous_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || previous_sha="$current_sha" echo "ForgeFlow request: $REQUEST_ID" echo "Target: $REPOSITORY / $ENVIRONMENT" echo "Current SHA: $current_sha" echo "Requested SHA: $SHA" on_error() { local exit_code=$? local actual_sha trap - ERR actual_sha="$(git -C "$APP_DIR" rev-parse HEAD 2>/dev/null || printf '%s' "$current_sha")" write_status "unhealthy" "$actual_sha" "$previous_sha" "$exit_code" || true echo "Deployment failed with exit code $exit_code" >&2 exit "$exit_code" } trap on_error ERR git -C "$APP_DIR" fetch --prune origin "$BRANCH" git -C "$APP_DIR" cat-file -e "$SHA^{commit}" git -C "$APP_DIR" merge-base --is-ancestor "$SHA" "origin/$BRANCH" || { echo "Requested SHA is not part of origin/$BRANCH" >&2 exit 65 } write_status "deploying" "$current_sha" "$previous_sha" 0 git -C "$APP_DIR" reset --hard "$SHA" docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans for attempt in $(seq 1 30); do if curl --fail --silent --show-error --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then printf '%s\n' "$SHA" > "$APP_DIR/.forgeflow-live-sha" printf '%s\n' "$previous_sha" > "$APP_DIR/.forgeflow-previous-sha" chmod 0640 "$APP_DIR/.forgeflow-live-sha" "$APP_DIR/.forgeflow-previous-sha" write_status "healthy" "$SHA" "$previous_sha" 0 trap - ERR echo "Deployment healthy at $SHA" exit 0 fi echo "Healthcheck attempt $attempt/30 did not pass yet" sleep 2 done trap - ERR write_status "unhealthy" "$SHA" "$previous_sha" 70 echo "Healthcheck failed after deployment" >&2 exit 70