name: Deploy NAS Compose on: workflow_dispatch: inputs: host: description: "NAS SSH host" required: true default: "192.168.100.19" port: description: "NAS SSH port" required: true default: "22" user: description: "NAS SSH user" required: true default: "admin" deploy_base: description: "Base deployment directory on the NAS" required: true default: "/home/admin/geo-rankly" image_tag: description: "Docker image tag. Defaults to the short commit SHA." required: false default: "" keep_releases: description: "Number of old release directories to keep" required: false default: "5" permissions: contents: read jobs: deploy: name: Deploy Docker Compose to NAS runs-on: ubuntu-latest timeout-minutes: 180 env: NAS_HOST: ${{ inputs.host }} NAS_PORT: ${{ inputs.port }} NAS_USER: ${{ inputs.user }} DEPLOY_BASE: ${{ inputs.deploy_base }} REQUESTED_IMAGE_TAG: ${{ inputs.image_tag }} KEEP_RELEASES: ${{ inputs.keep_releases }} NAS_SSH_PASSWORD: ${{ secrets.NAS_SSH_PASSWORD }} NAS_DEPLOY_ENV: ${{ secrets.NAS_DEPLOY_ENV }} DOCKER_BUILDKIT: "1" steps: - name: Checkout uses: actions/checkout@v4 - name: Install SSH tools run: | apt-get update apt-get install -y openssh-client sshpass tar - name: Resolve deployment metadata run: | set -eu if [ -z "${NAS_SSH_PASSWORD:-}" ]; then echo "::error::Missing repository secret NAS_SSH_PASSWORD" exit 1 fi case "${NAS_PORT}" in *[!0-9]*|"") echo "::error::NAS SSH port must be numeric" exit 1 ;; esac case "${DEPLOY_BASE}" in /*) ;; *) echo "::error::deploy_base must be an absolute path" exit 1 ;; esac case "${DEPLOY_BASE}" in *[!A-Za-z0-9_./-]*) echo "::error::deploy_base contains unsupported characters" exit 1 ;; esac case "${KEEP_RELEASES}" in *[!0-9]*|"") KEEP_RELEASES=5 ;; esac image_tag="${REQUESTED_IMAGE_TAG}" if [ -z "${image_tag}" ]; then image_tag="$(git rev-parse --short=12 HEAD)" fi case "${image_tag}" in *[!A-Za-z0-9_.-]*|"") echo "::error::image_tag may only contain letters, numbers, underscore, dot, and dash" exit 1 ;; esac release_id="$(date -u +%Y%m%d%H%M%S)-$(git rev-parse --short=12 HEAD)" { echo "IMAGE_TAG=${image_tag}" echo "RELEASE_ID=${release_id}" echo "KEEP_RELEASES=${KEEP_RELEASES}" } >> "${GITHUB_ENV}" echo "Image tag: ${image_tag}" echo "Release ID: ${release_id}" echo "Deploy base: ${DEPLOY_BASE}" - name: Prepare SSH known hosts run: | set -eu mkdir -p ~/.ssh chmod 700 ~/.ssh ssh-keyscan -p "${NAS_PORT}" "${NAS_HOST}" >> ~/.ssh/known_hosts - name: Validate migrations on empty Postgres run: | set -eu test_name="geo-migration-preflight-${GITHUB_RUN_ID:-manual}-${GITHUB_RUN_NUMBER:-0}" network_name="${test_name}-net" postgres_name="${test_name}-postgres" migrate_image="${test_name}-migrate" cleanup() { docker rm -f "${postgres_name}" >/dev/null 2>&1 || true docker network rm "${network_name}" >/dev/null 2>&1 || true docker image rm "${migrate_image}" >/dev/null 2>&1 || true } trap cleanup EXIT INT TERM docker network create "${network_name}" >/dev/null docker run -d \ --name "${postgres_name}" \ --network "${network_name}" \ -e POSTGRES_DB=geo \ -e POSTGRES_USER=geo \ -e POSTGRES_PASSWORD=geo_dev \ postgres:16-alpine >/dev/null echo "Waiting for empty Postgres..." for i in $(seq 1 60); do if docker exec "${postgres_name}" pg_isready -U geo -d geo >/dev/null 2>&1; then break fi if [ "${i}" -eq 60 ]; then docker logs "${postgres_name}" || true echo "::error::Postgres did not become ready" exit 1 fi sleep 1 done docker exec "${postgres_name}" createdb -U geo geo_monitoring echo "Building migration image..." DOCKER_BUILDKIT=1 docker build \ --target migrate \ --tag "${migrate_image}" \ -f server/Dockerfile \ server/ database_url="postgres://geo:geo_dev@${postgres_name}:5432/geo?sslmode=disable" monitoring_database_url="postgres://geo:geo_dev@${postgres_name}:5432/geo_monitoring?sslmode=disable" ops_database_url="postgres://geo:geo_dev@${postgres_name}:5432/geo?sslmode=disable&x-migrations-table=schema_migrations_ops" echo "Running tenant migrations on an empty database..." docker run --rm --network "${network_name}" "${migrate_image}" \ -path /migrations \ -database "${database_url}" \ up echo "Running monitoring migrations on an empty database..." docker run --rm --network "${network_name}" "${migrate_image}" \ -path /migrations_monitoring \ -database "${monitoring_database_url}" \ up echo "Running ops migrations on an empty database..." docker run --rm --network "${network_name}" "${migrate_image}" \ -path /migrations_ops \ -database "${ops_database_url}" \ up echo "Migration preflight passed." - name: Upload release to NAS run: | set -eu remote="${NAS_USER}@${NAS_HOST}" ssh_opts="-p ${NAS_PORT} -o BatchMode=no -o StrictHostKeyChecking=yes" release_dir="${DEPLOY_BASE}/releases/${RELEASE_ID}" SSHPASS="${NAS_SSH_PASSWORD}" sshpass -e ssh ${ssh_opts} "${remote}" \ "mkdir -p '${release_dir}' '${DEPLOY_BASE}/shared'" git archive --format=tar.gz HEAD \ | SSHPASS="${NAS_SSH_PASSWORD}" sshpass -e ssh ${ssh_opts} "${remote}" \ "tar -xzf - -C '${release_dir}'" - name: Upload deploy env when provided run: | set -eu if [ -z "${NAS_DEPLOY_ENV:-}" ]; then echo "NAS_DEPLOY_ENV is empty; keeping existing NAS .env or creating from .env.example during deploy." exit 0 fi remote="${NAS_USER}@${NAS_HOST}" ssh_opts="-p ${NAS_PORT} -o BatchMode=no -o StrictHostKeyChecking=yes" printf '%s\n' "${NAS_DEPLOY_ENV}" \ | SSHPASS="${NAS_SSH_PASSWORD}" sshpass -e ssh ${ssh_opts} "${remote}" \ "cat > '${DEPLOY_BASE}/shared/.env' && chmod 600 '${DEPLOY_BASE}/shared/.env'" - name: Deploy with Docker Compose run: | set -eu remote="${NAS_USER}@${NAS_HOST}" ssh_opts="-p ${NAS_PORT} -o BatchMode=no -o StrictHostKeyChecking=yes" sudo_password_b64="$(printf '%s' "${NAS_SSH_PASSWORD}" | base64 | tr -d '\n')" { printf 'SUDO_PASSWORD_B64=%s\n' "${sudo_password_b64}" cat <<'REMOTE_SCRIPT' set -eu release_dir="${DEPLOY_BASE}/releases/${RELEASE_ID}" shared_dir="${DEPLOY_BASE}/shared" lock_dir="${DEPLOY_BASE}/.deploy.lock" if ! mkdir "${lock_dir}" 2>/dev/null; then echo "Another deployment is already running: ${lock_dir}" exit 1 fi trap 'rmdir "${lock_dir}"' EXIT INT TERM if [ ! -f "${shared_dir}/.env" ]; then cp "${release_dir}/deploy/.env.example" "${shared_dir}/.env" chmod 600 "${shared_dir}/.env" echo "Created ${shared_dir}/.env from .env.example. Edit it on the NAS before production traffic if secrets are still placeholders." fi SUDO_PASSWORD="$(printf '%s' "${SUDO_PASSWORD_B64}" | base64 -d 2>/dev/null || true)" unset SUDO_PASSWORD_B64 docker_cmd() { if [ "${DOCKER_MODE}" = "sudo" ]; then printf '%s\n' "${SUDO_PASSWORD}" | sudo -S -p "" docker "$@" else docker "$@" fi } compose_cmd() { docker_cmd compose "$@" } if docker info >/dev/null 2>&1; then DOCKER_MODE="direct" elif command -v sudo >/dev/null 2>&1 && [ -n "${SUDO_PASSWORD}" ] && printf '%s\n' "${SUDO_PASSWORD}" | sudo -S -p "" docker info >/dev/null 2>&1; then DOCKER_MODE="sudo" else echo "Cannot access Docker daemon as $(id -un). Add the user to the docker group or allow sudo docker." exit 1 fi echo "Docker access mode: ${DOCKER_MODE}" compose_cmd version IMAGE_TAG="${IMAGE_TAG}" DOCKER_BUILDKIT=1 compose_cmd \ --env-file "${shared_dir}/.env" \ -f "${release_dir}/deploy/docker-compose.yaml" \ config >/tmp/geo-rankly-compose.yaml IMAGE_TAG="${IMAGE_TAG}" DOCKER_BUILDKIT=1 compose_cmd \ --env-file "${shared_dir}/.env" \ -f "${release_dir}/deploy/docker-compose.yaml" \ up -d --build --remove-orphans ln -sfn "${release_dir}" "${DEPLOY_BASE}/current" IMAGE_TAG="${IMAGE_TAG}" compose_cmd \ --env-file "${shared_dir}/.env" \ -f "${release_dir}/deploy/docker-compose.yaml" \ ps if [ "${KEEP_RELEASES}" -gt 0 ] 2>/dev/null; then find "${DEPLOY_BASE}/releases" -mindepth 1 -maxdepth 1 -type d \ | sort -r \ | awk "NR>${KEEP_RELEASES}" \ | xargs -r rm -rf fi REMOTE_SCRIPT } | SSHPASS="${NAS_SSH_PASSWORD}" sshpass -e ssh ${ssh_opts} "${remote}" \ "DEPLOY_BASE='${DEPLOY_BASE}' RELEASE_ID='${RELEASE_ID}' IMAGE_TAG='${IMAGE_TAG}' KEEP_RELEASES='${KEEP_RELEASES}' sh -se" - name: Deployment summary run: | summary_file="${GITHUB_STEP_SUMMARY:-/tmp/nas-compose-deploy-summary.md}" { echo "### NAS Docker Compose deployment" echo "" echo "- Host: ${NAS_HOST}:${NAS_PORT}" echo "- Deploy base: ${DEPLOY_BASE}" echo "- Release: ${RELEASE_ID}" echo "- Image tag: ${IMAGE_TAG}" echo "" echo "Useful NAS commands:" echo "" echo '```sh' echo "cd ${DEPLOY_BASE}/current" echo "docker compose --env-file ${DEPLOY_BASE}/shared/.env -f deploy/docker-compose.yaml ps" echo "docker compose --env-file ${DEPLOY_BASE}/shared/.env -f deploy/docker-compose.yaml logs -f tenant-api" echo '```' } >> "${summary_file}"