#!/usr/bin/env bash set -Eeuo pipefail : "${DEPLOY_IMAGE:?DEPLOY_IMAGE is required}" DEPLOY_NAME=${DEPLOY_NAME:-adcloud-server-test} DEPLOY_PORT=${DEPLOY_PORT:-18080} DEPLOY_BIND_IP=${DEPLOY_BIND_IP:-0.0.0.0} DEPLOY_REPOSITORY=${DEPLOY_REPOSITORY:-Zayden-Jung/adcloud-server} DEPLOY_REVISION=${DEPLOY_REVISION:-local} DEPLOY_CORS_ORIGINS=${DEPLOY_CORS_ORIGINS:-http://localhost:3000,http://localhost:5173,http://localhost:5174} [[ "$DEPLOY_NAME" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,80}$ ]] || { echo 'Invalid container name'; exit 2; } [[ "$DEPLOY_PORT" =~ ^[0-9]{1,5}$ ]] && ((10#$DEPLOY_PORT >= 1024 && 10#$DEPLOY_PORT <= 65535)) || { echo 'Invalid deployment port'; exit 2; } [[ "$DEPLOY_BIND_IP" == '0.0.0.0' || "$DEPLOY_BIND_IP" == '127.0.0.1' ]] || { echo 'Bind IP must be 0.0.0.0 or 127.0.0.1'; exit 2; } managed_label=io.entroia.adcloud.managed repository_label=io.entroia.adcloud.repository data_volume="$DEPLOY_NAME-data" config_volume="$DEPLOY_NAME-config" candidate="$DEPLOY_NAME-candidate" backup="$DEPLOY_NAME-previous" lock="$DEPLOY_NAME-deploy-lock" lock_acquired=false previous_saved=false new_started=false successful=false owned_container() { [[ "$(docker inspect --format "{{index .Config.Labels \"$managed_label\"}}" "$1")" == true && "$(docker inspect --format "{{index .Config.Labels \"$repository_label\"}}" "$1")" == "$DEPLOY_REPOSITORY" ]] } exists() { docker container inspect "$1" >/dev/null 2>&1; } cleanup() { local result=$? trap - EXIT if ! $successful && $new_started && exists "$DEPLOY_NAME" && owned_container "$DEPLOY_NAME"; then docker rm -f "$DEPLOY_NAME" >/dev/null || true fi if ! $successful && $previous_saved; then docker rename "$backup" "$DEPLOY_NAME" >/dev/null && docker start "$DEPLOY_NAME" >/dev/null || true echo 'Deployment failed; previous container restored where possible.' fi if $lock_acquired; then if exists "$candidate" && owned_container "$candidate"; then docker rm -f "$candidate" >/dev/null || true; fi docker rm "$lock" >/dev/null || true fi exit "$result" } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM # Docker's unique container name gives a daemon-wide deployment lock. # Never remove another job's lock automatically. for attempt in $(seq 1 60); do if docker create --name "$lock" --label "$managed_label=true" --label "$repository_label=$DEPLOY_REPOSITORY" --entrypoint /bin/true "$DEPLOY_IMAGE" >/dev/null 2>&1; then lock_acquired=true break fi sleep 2 done $lock_acquired || { echo 'Cannot acquire deployment lock; check daemon or a stale lock.'; exit 1; } for name in "$candidate" "$backup"; do if exists "$name"; then owned_container "$name" || { echo "Refusing to alter unmanaged container $name"; exit 1; } # A retained backup can be the only surviving previous service. echo "Container $name already exists; inspect the previous deployment before retrying." exit 1 fi done if exists "$DEPLOY_NAME"; then owned_container "$DEPLOY_NAME" || { echo 'Refusing to replace an unmanaged container'; exit 1; } fi for volume in "$data_volume" "$config_volume"; do if docker volume inspect "$volume" >/dev/null 2>&1; then [[ "$(docker volume inspect --format "{{index .Labels \"$repository_label\"}}" "$volume")" == "$DEPLOY_REPOSITORY" ]] || { echo "Refusing unmanaged volume $volume"; exit 1; } else docker volume create --label "$managed_label=true" --label "$repository_label=$DEPLOY_REPOSITORY" "$volume" >/dev/null fi done common=(--label "$managed_label=true" --label "$repository_label=$DEPLOY_REPOSITORY" --label "io.entroia.adcloud.revision=$DEPLOY_REVISION" --read-only --cap-drop ALL --security-opt no-new-privileges:true --pids-limit 128 --memory 512m --cpus 1 --tmpfs /tmp:rw,noexec,nosuid,size=64m --env "CORS_ORIGINS=$DEPLOY_CORS_ORIGINS" --env COOKIE_SECURE=false) wait_healthy() { local state for attempt in $(seq 1 60); do state=$(docker inspect --format '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}}' "$1") [[ "$state" == 'running healthy' ]] && return 0 [[ "$state" == exited* || "$state" == dead* ]] && break sleep 2 done echo "Container $1 failed health check; inspect its logs on the server." return 1 } # Validate with disposable data before stopping the currently deployed service. docker run -d --name "$candidate" "${common[@]}" --tmpfs /data:rw,uid=10001,gid=10001,mode=0700 --tmpfs /config:rw,uid=10001,gid=10001,mode=0700 "$DEPLOY_IMAGE" >/dev/null wait_healthy "$candidate" docker exec "$candidate" python /app/scripts/smoke_api.py docker rm -f "$candidate" >/dev/null if exists "$DEPLOY_NAME"; then docker stop --time 20 "$DEPLOY_NAME" >/dev/null docker rename "$DEPLOY_NAME" "$backup" previous_saved=true fi new_started=true docker run -d --name "$DEPLOY_NAME" "${common[@]}" --restart unless-stopped --publish "$DEPLOY_BIND_IP:$DEPLOY_PORT:8000" --volume "$data_volume:/data" --volume "$config_volume:/config" "$DEPLOY_IMAGE" >/dev/null wait_healthy "$DEPLOY_NAME" # Probe the daemon HOST namespace, not the isolated Actions job's localhost. docker run --rm --network host --read-only --cap-drop ALL --security-opt no-new-privileges:true --pids-limit 64 --memory 128m --volume "$config_volume:/config:ro" --entrypoint python "$DEPLOY_IMAGE" /app/scripts/smoke_api.py --base-url "http://127.0.0.1:$DEPLOY_PORT" successful=true if $previous_saved; then docker rm "$backup" >/dev/null; fi printf 'Deployment ready: %s → container 8000; container=%s; revision=%s\n' "$DEPLOY_PORT" "$DEPLOY_NAME" "$DEPLOY_REVISION"