#!/usr/bin/env bash set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$ROOT" GEOINTEL_DEPLOY_LOCK_FILE="${GEOINTEL_DEPLOY_LOCK_FILE:-/tmp/geointel-release-deploy.lock}" if ! command -v flock >/dev/null 2>&1; then echo "GeoIntel release deployment requires flock to prevent concurrent container replacement." >&2 exit 2 fi exec 9>"$GEOINTEL_DEPLOY_LOCK_FILE" if ! flock -n 9; then echo "Another GeoIntel release deployment is already running." >&2 exit 3 fi if [ -f .env ]; then set -a # shellcheck disable=SC1091 . ./.env set +a fi if [ -n "${DEPLOY_GEOINTEL_INSTALL_AI:-}" ]; then GEOINTEL_INSTALL_AI="$DEPLOY_GEOINTEL_INSTALL_AI" fi GEOINTEL_INSTALL_AI="${GEOINTEL_INSTALL_AI:-false}" GEOINTEL_APP_VERSION="$(tr -d '[:space:]' < VERSION)" if ! [[ "$GEOINTEL_APP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then echo "Invalid semantic version in VERSION: ${GEOINTEL_APP_VERSION}" >&2 exit 2 fi # Hash of everything that actually lands in the image. This is the honest # answer to "does this image need rebuilding?" — unlike a git SHA, it changes # when working-tree files change without a commit. source_tree_hash() { local hash="" command -v sha1sum >/dev/null 2>&1 || return 1 hash="$( find backend frontend deploy scripts fixtures VERSION \ -type f \ ! -path '*/node_modules/*' \ ! -path '*/dist/*' \ ! -path '*/__pycache__/*' \ ! -path '*/.pytest_cache/*' \ ! -name '*.pyc' \ -print0 2>/dev/null \ | sort -z \ | xargs -0 sha1sum 2>/dev/null \ | sha1sum \ | cut -c1-40 )" || return 1 [ -n "$hash" ] || return 1 printf '%s' "$hash" } resolve_build_sha() { local head="" content="" # 1. Explicit override wins. if [ -n "${GEOINTEL_BUILD_SHA:-}" ]; then printf '%s' "$GEOINTEL_BUILD_SHA" return 0 fi # 2. Git checkout, but only when the working tree matches the commit. # A manually copied tree often carries .git along while the files on disk # have moved on. Trusting HEAD there produces an unchanged image tag, and # the deploy silently reuses the previous image instead of rebuilding. if command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then head="$(git rev-parse HEAD 2>/dev/null || true)" if [ -n "$head" ]; then if [ -z "$(git status --porcelain 2>/dev/null)" ]; then printf '%s' "$head" return 0 fi echo "Working tree differs from HEAD; tagging this build by content." >&2 content="$(source_tree_hash || true)" if [ -n "$content" ]; then printf '%s-wip%s' "${head:0:12}" "${content:0:12}" return 0 fi fi fi # 3. Manually copied tree with a RELEASE_SHA marker file. if [ -f RELEASE_SHA ]; then tr -d '[:space:]' < RELEASE_SHA return 0 fi # 4. No git: content hash, so an unchanged redeploy still reuses its image. content="$(source_tree_hash || true)" if [ -n "$content" ]; then printf '%s' "$content" return 0 fi # 5. Last resort: unique per deploy. printf 'manual%s' "$(date -u +%Y%m%d%H%M%S)" } GEOINTEL_BUILD_SHA="$(resolve_build_sha)" if [ -z "$GEOINTEL_BUILD_SHA" ]; then echo "Could not determine a build revision for this deployment." >&2 exit 2 fi echo "Build revision: ${GEOINTEL_BUILD_SHA}" GEOINTEL_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" GEOINTEL_IMAGE_REPOSITORY="${GEOINTEL_IMAGE_REPOSITORY:-geointel-all-in-one}" if [ "$GEOINTEL_INSTALL_AI" = "true" ]; then GEOINTEL_RELEASE_VARIANT="ai" else GEOINTEL_RELEASE_VARIANT="gis" fi GEOINTEL_RELEASE_IMAGE="${GEOINTEL_IMAGE_REPOSITORY}:${GEOINTEL_BUILD_SHA}-${GEOINTEL_RELEASE_VARIANT}" GEOINTEL_PREVIOUS_IMAGE="${GEOINTEL_IMAGE_REPOSITORY}:previous" FRONTEND_URL="${FRONTEND_URL:-http://127.0.0.1:${GEOINTEL_FRONTEND_PORT:-1202}}" wait_for_geointel_health() { local status="" for attempt in $(seq 1 480); do status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' geointel 2>/dev/null || true)" if [ "$status" = "healthy" ]; then echo "GeoIntel container is healthy after attempt ${attempt}." return 0 fi if [ "$status" = "unhealthy" ] || [ "$status" = "exited" ] || [ "$status" = "dead" ]; then echo "GeoIntel container entered terminal state: ${status}" >&2 docker logs --tail 120 geointel >&2 || true return 1 fi sleep 2 done echo "GeoIntel container did not become healthy (last state: ${status:-missing})." >&2 docker logs --tail 120 geointel >&2 || true return 1 } start_image() { local image="$1" GEOINTEL_IMAGE="$image" bash deploy/unraid/run-dockerman-container.sh wait_for_geointel_health } rollback_previous() { if ! docker image inspect "$GEOINTEL_PREVIOUS_IMAGE" >/dev/null 2>&1; then echo "Automatic rollback unavailable: ${GEOINTEL_PREVIOUS_IMAGE} does not exist." >&2 return 1 fi echo "Rolling back to ${GEOINTEL_PREVIOUS_IMAGE}..." start_image "$GEOINTEL_PREVIOUS_IMAGE" } docker compose -f docker-compose.unraid.yml config >/dev/null current_image_id="$(docker inspect --format '{{.Image}}' geointel 2>/dev/null || true)" release_image_id="$( docker image inspect --format '{{.Id}}' "$GEOINTEL_RELEASE_IMAGE" 2>/dev/null || true )" if ( [ -n "$current_image_id" ] && [ "$current_image_id" != "$release_image_id" ] && docker image inspect "$current_image_id" >/dev/null 2>&1 ); then docker tag "$current_image_id" "$GEOINTEL_PREVIOUS_IMAGE" elif [ -n "$current_image_id" ] && [ "$current_image_id" = "$release_image_id" ]; then echo "Current container already uses ${GEOINTEL_RELEASE_IMAGE}; preserving the existing previous image." fi if docker image inspect "$GEOINTEL_RELEASE_IMAGE" >/dev/null 2>&1; then stored_revision="$( docker image inspect \ --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' \ "$GEOINTEL_RELEASE_IMAGE" )" stored_ai="$( docker image inspect \ --format '{{index .Config.Labels "io.geointel.ai.enabled"}}' \ "$GEOINTEL_RELEASE_IMAGE" )" stored_version="$( docker image inspect \ --format '{{index .Config.Labels "org.opencontainers.image.version"}}' \ "$GEOINTEL_RELEASE_IMAGE" )" if ( [ "$stored_revision" != "$GEOINTEL_BUILD_SHA" ] || [ "$stored_ai" != "$GEOINTEL_INSTALL_AI" ] || [ "$stored_version" != "$GEOINTEL_APP_VERSION" ] ); then echo "Immutable release tag has conflicting metadata: ${GEOINTEL_RELEASE_IMAGE}" >&2 exit 2 fi echo "Reusing existing immutable image ${GEOINTEL_RELEASE_IMAGE}." docker tag "$GEOINTEL_RELEASE_IMAGE" "${GEOINTEL_IMAGE_REPOSITORY}:latest" else docker build \ --build-arg GEOINTEL_INSTALL_AI="$GEOINTEL_INSTALL_AI" \ --build-arg GEOINTEL_BUILD_SHA="$GEOINTEL_BUILD_SHA" \ --build-arg GEOINTEL_BUILD_TIME="$GEOINTEL_BUILD_TIME" \ --build-arg GEOINTEL_APP_VERSION="$GEOINTEL_APP_VERSION" \ -f deploy/unraid/Dockerfile.all-in-one \ -t "$GEOINTEL_RELEASE_IMAGE" \ -t "${GEOINTEL_IMAGE_REPOSITORY}:latest" \ . fi if ! start_image "$GEOINTEL_RELEASE_IMAGE"; then rollback_previous || true exit 1 fi if [ -f scripts/live_migration_smoke.sh ]; then if ! LIVE_SMOKE_CONTAINER=geointel bash scripts/live_migration_smoke.sh; then rollback_previous || true exit 1 fi fi if [ -f scripts/verify_browser_runtime.sh ]; then if ! bash scripts/verify_browser_runtime.sh "$FRONTEND_URL"; then rollback_previous || true exit 1 fi fi echo "Deployed immutable image ${GEOINTEL_RELEASE_IMAGE}." docker image inspect \ --format 'version={{index .Config.Labels "org.opencontainers.image.version"}} revision={{index .Config.Labels "org.opencontainers.image.revision"}} created={{index .Config.Labels "org.opencontainers.image.created"}}' \ "$GEOINTEL_RELEASE_IMAGE"