Files
geo/.gitea/workflows/nas-compose-service-deploy.yml
T
2026-05-01 22:30:23 +08:00

514 lines
17 KiB
YAML

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"
password_source:
description: "NAS SSH password source"
required: true
default: "repository_secret"
type: choice
options:
- repository_secret
- workflow_input
ssh_password:
description: "NAS SSH password. Used only when password_source is workflow_input."
required: false
default: ""
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 }}
NAS_SSH_PASSWORD_SOURCE: ${{ inputs.password_source }}
NAS_SSH_PASSWORD_INPUT: ${{ inputs.ssh_password }}
NAS_SSH_PASSWORD_SECRET: ${{ secrets.NAS_SSH_PASSWORD }}
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_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
case "${NAS_SSH_PASSWORD_SOURCE:-repository_secret}" in
repository_secret)
nas_ssh_password="${NAS_SSH_PASSWORD_SECRET:-}"
;;
workflow_input)
nas_ssh_password="${NAS_SSH_PASSWORD_INPUT:-}"
;;
*)
echo "::error::password_source must be repository_secret or workflow_input"
exit 1
;;
esac
if [ -n "${nas_ssh_password}" ]; then
echo "::add-mask::${nas_ssh_password}"
fi
{
echo "NAS_SSH_PASSWORD<<__NAS_SSH_PASSWORD__"
printf '%s\n' "${nas_ssh_password}"
echo "__NAS_SSH_PASSWORD__"
echo "NAS_SSH_PASSWORD_INPUT="
echo "NAS_SSH_PASSWORD_SECRET="
} >> "${GITHUB_ENV}"
if [ -z "${nas_ssh_password}" ]; then
if [ "${NAS_SSH_PASSWORD_SOURCE:-repository_secret}" = "workflow_input" ]; then
echo "::error::Missing workflow input ssh_password"
else
echo "::error::Missing repository secret NAS_SSH_PASSWORD"
fi
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}"