From 5cb8048174cea3130dc3fa5fbc78abfd91295ec3 Mon Sep 17 00:00:00 2001 From: liangxu Date: Fri, 1 May 2026 20:52:54 +0800 Subject: [PATCH] ci: add selectable nas service deploy --- .../workflows/nas-compose-service-deploy.yml | 470 ++++++++++++++++++ 1 file changed, 470 insertions(+) create mode 100644 .gitea/workflows/nas-compose-service-deploy.yml diff --git a/.gitea/workflows/nas-compose-service-deploy.yml b/.gitea/workflows/nas-compose-service-deploy.yml new file mode 100644 index 0000000..df17843 --- /dev/null +++ b/.gitea/workflows/nas-compose-service-deploy.yml @@ -0,0 +1,470 @@ +name: Deploy NAS Compose Service + +on: + workflow_dispatch: + inputs: + service: + description: "Service or group to update" + required: true + default: "frontend" + type: choice + options: + - frontend + - ops-web + - tenant-api + - ops-api + - worker-generate + - kol-assist-worker + - scheduler + - web + - backend + - all-apps + run_migrations: + description: "Run database migrations before restart" + required: true + default: "auto" + type: choice + options: + - auto + - yes + - no + validate_migrations: + description: "Validate migrations on an empty Postgres first when migrations run" + required: true + default: "true" + type: choice + options: + - "true" + - "false" + 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: "/vol1/1000/application/geo" + 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. 0 keeps all releases." + required: false + default: "0" + +permissions: + contents: read + +jobs: + deploy-service: + name: Deploy selected service 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 }} + SELECTED_SERVICE: ${{ inputs.service }} + REQUESTED_IMAGE_TAG: ${{ inputs.image_tag }} + REQUESTED_RUN_MIGRATIONS: ${{ inputs.run_migrations }} + VALIDATE_MIGRATIONS: ${{ inputs.validate_migrations }} + 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=0 ;; + esac + + auto_migrations=false + case "${SELECTED_SERVICE}" in + frontend) + service_list="frontend" + ;; + ops-web) + service_list="ops-web" + ;; + tenant-api) + service_list="tenant-api" + auto_migrations=true + ;; + ops-api) + service_list="ops-api" + auto_migrations=true + ;; + worker-generate) + service_list="worker-generate" + auto_migrations=true + ;; + kol-assist-worker) + service_list="kol-assist-worker" + auto_migrations=true + ;; + scheduler) + service_list="scheduler" + auto_migrations=true + ;; + web) + service_list="frontend ops-web" + ;; + backend) + service_list="tenant-api ops-api worker-generate kol-assist-worker scheduler" + auto_migrations=true + ;; + all-apps) + service_list="tenant-api ops-api worker-generate kol-assist-worker scheduler frontend ops-web" + auto_migrations=true + ;; + *) + echo "::error::Unsupported service: ${SELECTED_SERVICE}" + exit 1 + ;; + esac + + case "${REQUESTED_RUN_MIGRATIONS}" in + auto) + run_migrations="${auto_migrations}" + ;; + yes) + run_migrations=true + ;; + no) + run_migrations=false + ;; + *) + echo "::error::run_migrations must be auto, yes, or no" + exit 1 + ;; + esac + + case "${VALIDATE_MIGRATIONS}" in + true|false) ;; + *) + echo "::error::validate_migrations must be true or false" + exit 1 + ;; + 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 + + service_slug="$(printf '%s' "${SELECTED_SERVICE}" | tr -c 'A-Za-z0-9_.-' '-')" + release_id="$(date -u +%Y%m%d%H%M%S)-$(git rev-parse --short=12 HEAD)-${service_slug}" + + { + echo "IMAGE_TAG=${image_tag}" + echo "RELEASE_ID=${release_id}" + echo "SERVICE_LIST=${service_list}" + echo "RUN_MIGRATIONS=${run_migrations}" + echo "KEEP_RELEASES=${KEEP_RELEASES}" + } >> "${GITHUB_ENV}" + + echo "Selected service: ${SELECTED_SERVICE}" + echo "Compose services: ${service_list}" + echo "Run migrations: ${run_migrations}" + echo "Validate migrations: ${VALIDATE_MIGRATIONS}" + 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 + + if [ "${RUN_MIGRATIONS}" != "true" ] || [ "${VALIDATE_MIGRATIONS}" != "true" ]; then + echo "Skipping migration preflight." + exit 0 + fi + + test_name="geo-service-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 selected service 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-service.yaml + + if [ "${RUN_MIGRATIONS}" = "true" ]; then + echo "Running database migrations..." + IMAGE_TAG="${IMAGE_TAG}" compose_cmd \ + --env-file "${shared_dir}/.env" \ + -f "${release_dir}/deploy/docker-compose.yaml" \ + rm -f -s migrate >/dev/null 2>&1 || true + + IMAGE_TAG="${IMAGE_TAG}" DOCKER_BUILDKIT=1 compose_cmd \ + --env-file "${shared_dir}/.env" \ + -f "${release_dir}/deploy/docker-compose.yaml" \ + up --build --force-recreate migrate + fi + + echo "Building selected service(s): ${SERVICE_LIST}" + IMAGE_TAG="${IMAGE_TAG}" DOCKER_BUILDKIT=1 compose_cmd \ + --env-file "${shared_dir}/.env" \ + -f "${release_dir}/deploy/docker-compose.yaml" \ + build ${SERVICE_LIST} + + echo "Restarting selected service(s): ${SERVICE_LIST}" + IMAGE_TAG="${IMAGE_TAG}" compose_cmd \ + --env-file "${shared_dir}/.env" \ + -f "${release_dir}/deploy/docker-compose.yaml" \ + up -d --no-deps --force-recreate ${SERVICE_LIST} + + 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 ${SERVICE_LIST} + + 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 + else + echo "Release cleanup disabled for component deployment." + 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}' SERVICE_LIST='${SERVICE_LIST}' RUN_MIGRATIONS='${RUN_MIGRATIONS}' KEEP_RELEASES='${KEEP_RELEASES}' sh -se" + + - name: Deployment summary + run: | + summary_file="${GITHUB_STEP_SUMMARY:-/tmp/nas-compose-service-deploy-summary.md}" + { + echo "### NAS selected-service deployment" + echo "" + echo "- Host: ${NAS_HOST}:${NAS_PORT}" + echo "- Deploy base: ${DEPLOY_BASE}" + echo "- Release: ${RELEASE_ID}" + echo "- Image tag: ${IMAGE_TAG}" + echo "- Selected input: ${SELECTED_SERVICE}" + echo "- Compose service(s): ${SERVICE_LIST}" + echo "- Run migrations: ${RUN_MIGRATIONS}" + echo "" + echo "Useful NAS commands:" + echo "" + echo '```sh' + echo "cd ${DEPLOY_BASE}/current/deploy" + echo "docker compose --env-file ${DEPLOY_BASE}/shared/.env ps ${SERVICE_LIST}" + echo "docker compose --env-file ${DEPLOY_BASE}/shared/.env logs -f ${SERVICE_LIST}" + echo '```' + } >> "${summary_file}"