443 lines
14 KiB
YAML
443 lines
14 KiB
YAML
name: Deploy Production K3s
|
|
|
|
"on":
|
|
workflow_dispatch:
|
|
inputs:
|
|
target:
|
|
description: "Service or group to deploy to production k3s"
|
|
required: true
|
|
default: "ops-web"
|
|
type: choice
|
|
options:
|
|
- frontend
|
|
- ops-web
|
|
- browser-fetch
|
|
- tenant-api
|
|
- ops-api
|
|
- worker-generate
|
|
- kol-assist-worker
|
|
- scheduler
|
|
- web
|
|
- backend
|
|
- all-apps
|
|
- migrate
|
|
image_tag:
|
|
description: "Image tag to deploy. Empty means current commit short SHA. Use latest only if you really want the moving tag."
|
|
required: false
|
|
default: ""
|
|
run_migrations:
|
|
description: "Run database migrations before app rollout"
|
|
required: true
|
|
default: "auto"
|
|
type: choice
|
|
options:
|
|
- auto
|
|
- yes
|
|
- no
|
|
host:
|
|
description: "Production k3s SSH host"
|
|
required: true
|
|
default: "39.105.229.239"
|
|
port:
|
|
description: "Production k3s SSH port"
|
|
required: true
|
|
default: "22"
|
|
user:
|
|
description: "Production k3s SSH user"
|
|
required: true
|
|
default: "root"
|
|
password_source:
|
|
description: "Production SSH password source"
|
|
required: true
|
|
default: "repository_secret"
|
|
type: choice
|
|
options:
|
|
- repository_secret
|
|
- workflow_input
|
|
ssh_password:
|
|
description: "Production SSH password. Used only when password_source=workflow_input."
|
|
required: false
|
|
default: ""
|
|
namespace:
|
|
description: "Kubernetes namespace"
|
|
required: true
|
|
default: "geo-rankly"
|
|
registry_host:
|
|
description: "NAS Gitea Registry host, without scheme"
|
|
required: true
|
|
default: "192.168.100.49:13000"
|
|
registry_owner:
|
|
description: "Gitea package owner"
|
|
required: true
|
|
default: "root"
|
|
registry_image_prefix:
|
|
description: "Image package prefix. Images are <prefix>/<service>."
|
|
required: true
|
|
default: "geo-rankly"
|
|
registry_username:
|
|
description: "Gitea Registry username"
|
|
required: true
|
|
default: "root"
|
|
registry_password_source:
|
|
description: "Gitea Registry password source"
|
|
required: true
|
|
default: "repository_secret"
|
|
type: choice
|
|
options:
|
|
- repository_secret
|
|
- workflow_input
|
|
registry_password:
|
|
description: "Gitea Registry password/token. Used only when registry_password_source=workflow_input."
|
|
required: false
|
|
default: ""
|
|
registry_plain_http:
|
|
description: "Registry uses plain HTTP and must be configured as a Docker insecure registry on the runner."
|
|
required: true
|
|
default: "true"
|
|
type: choice
|
|
options:
|
|
- "true"
|
|
- "false"
|
|
wait_timeout:
|
|
description: "Kubernetes rollout/migration wait timeout"
|
|
required: false
|
|
default: "10m"
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
deploy-production-k3s:
|
|
name: Deploy selected target to production k3s
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 180
|
|
env:
|
|
PROD_HOST: ${{ inputs.host }}
|
|
PROD_PORT: ${{ inputs.port }}
|
|
PROD_USER: ${{ inputs.user }}
|
|
PROD_SSH_PASSWORD_SOURCE: ${{ inputs.password_source }}
|
|
PROD_SSH_PASSWORD_INPUT: ${{ inputs.ssh_password }}
|
|
PROD_SSH_PASSWORD_SECRET: ${{ secrets.PROD_SSH_PASSWORD }}
|
|
K3S_NAMESPACE: ${{ inputs.namespace }}
|
|
REGISTRY_HOST: ${{ inputs.registry_host }}
|
|
REGISTRY_OWNER: ${{ inputs.registry_owner }}
|
|
REGISTRY_IMAGE_PREFIX: ${{ inputs.registry_image_prefix }}
|
|
REGISTRY_USERNAME: ${{ inputs.registry_username }}
|
|
REGISTRY_PASSWORD_SOURCE: ${{ inputs.registry_password_source }}
|
|
REGISTRY_PASSWORD_INPUT: ${{ inputs.registry_password }}
|
|
REGISTRY_PASSWORD_SECRET: ${{ secrets.REGISTRY_PASSWORD }}
|
|
REGISTRY_PLAIN_HTTP: ${{ inputs.registry_plain_http }}
|
|
SELECTED_TARGET: ${{ inputs.target }}
|
|
REQUESTED_IMAGE_TAG: ${{ inputs.image_tag }}
|
|
REQUESTED_RUN_MIGRATIONS: ${{ inputs.run_migrations }}
|
|
WAIT_TIMEOUT: ${{ inputs.wait_timeout }}
|
|
DOCKER_BUILDKIT: "1"
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Install deployment tools
|
|
run: |
|
|
apt-get update
|
|
apt-get install -y curl docker.io openssh-client sshpass tar
|
|
|
|
- name: Resolve deployment metadata
|
|
run: |
|
|
set -eu
|
|
|
|
PROD_HOST="${PROD_HOST#ssh://}"
|
|
PROD_HOST="${PROD_HOST%/}"
|
|
REGISTRY_HOST="${REGISTRY_HOST#http://}"
|
|
REGISTRY_HOST="${REGISTRY_HOST#https://}"
|
|
REGISTRY_HOST="${REGISTRY_HOST%/}"
|
|
|
|
case "${PROD_PORT}" in
|
|
*[!0-9]*|"")
|
|
echo "::error::Production SSH port must be numeric"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
case "${PROD_HOST}" in
|
|
*[!A-Za-z0-9_.:-]*|"")
|
|
echo "::error::Production host must be a host or IP without scheme"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
case "${PROD_USER}" in
|
|
*[!A-Za-z0-9_.@-]*|"")
|
|
echo "::error::Production SSH user contains unsupported characters"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
case "${PROD_SSH_PASSWORD_SOURCE:-repository_secret}" in
|
|
repository_secret)
|
|
prod_ssh_password="${PROD_SSH_PASSWORD_SECRET:-}"
|
|
;;
|
|
workflow_input)
|
|
prod_ssh_password="${PROD_SSH_PASSWORD_INPUT:-}"
|
|
;;
|
|
*)
|
|
echo "::error::password_source must be repository_secret or workflow_input"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
if [ -n "${prod_ssh_password}" ]; then
|
|
echo "::add-mask::${prod_ssh_password}"
|
|
fi
|
|
|
|
if [ -z "${prod_ssh_password}" ]; then
|
|
if [ "${PROD_SSH_PASSWORD_SOURCE:-repository_secret}" = "workflow_input" ]; then
|
|
echo "::error::Missing workflow input ssh_password"
|
|
else
|
|
echo "::error::Missing repository secret PROD_SSH_PASSWORD"
|
|
fi
|
|
exit 1
|
|
fi
|
|
|
|
case "${REGISTRY_PASSWORD_SOURCE:-repository_secret}" in
|
|
repository_secret)
|
|
registry_password="${REGISTRY_PASSWORD_SECRET:-}"
|
|
;;
|
|
workflow_input)
|
|
registry_password="${REGISTRY_PASSWORD_INPUT:-}"
|
|
;;
|
|
*)
|
|
echo "::error::registry_password_source must be repository_secret or workflow_input"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
if [ -n "${registry_password}" ]; then
|
|
echo "::add-mask::${registry_password}"
|
|
fi
|
|
|
|
if [ -z "${registry_password}" ]; then
|
|
if [ "${REGISTRY_PASSWORD_SOURCE:-repository_secret}" = "workflow_input" ]; then
|
|
echo "::error::Missing workflow input registry_password"
|
|
else
|
|
echo "::error::Missing repository secret REGISTRY_PASSWORD"
|
|
fi
|
|
exit 1
|
|
fi
|
|
|
|
case "${REGISTRY_HOST}" in
|
|
*[!A-Za-z0-9_.:-]*|"")
|
|
echo "::error::registry_host must be a host[:port] without scheme"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
case "${REGISTRY_OWNER}" in
|
|
*[!A-Za-z0-9_.-]*|"")
|
|
echo "::error::registry_owner contains unsupported characters"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
case "${REGISTRY_IMAGE_PREFIX}" in
|
|
*[!A-Za-z0-9_.-]*|"")
|
|
echo "::error::registry_image_prefix contains unsupported characters"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
case "${REGISTRY_USERNAME}" in
|
|
*[!A-Za-z0-9_.@-]*|"")
|
|
echo "::error::registry_username contains unsupported characters"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
case "${REGISTRY_PLAIN_HTTP}" in
|
|
true|false) ;;
|
|
*)
|
|
echo "::error::registry_plain_http must be true or false"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
case "${K3S_NAMESPACE}" in
|
|
*[!A-Za-z0-9_.-]*|"")
|
|
echo "::error::namespace contains unsupported characters"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
case "${WAIT_TIMEOUT}" in
|
|
*[!A-Za-z0-9_.-]*|"")
|
|
echo "::error::wait_timeout contains unsupported characters"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
all_app_images="tenant-api browser-fetch ops-api worker-generate kol-assist-worker scheduler frontend ops-web"
|
|
backend_images="tenant-api browser-fetch ops-api worker-generate kol-assist-worker scheduler"
|
|
deployment_list=""
|
|
auto_migrations=false
|
|
skip_deployments=false
|
|
|
|
case "${SELECTED_TARGET}" in
|
|
frontend)
|
|
deployment_list="frontend"
|
|
;;
|
|
ops-web)
|
|
deployment_list="ops-web"
|
|
;;
|
|
browser-fetch)
|
|
deployment_list="browser-fetch"
|
|
;;
|
|
tenant-api)
|
|
deployment_list="tenant-api"
|
|
auto_migrations=true
|
|
;;
|
|
ops-api)
|
|
deployment_list="ops-api"
|
|
auto_migrations=true
|
|
;;
|
|
worker-generate)
|
|
deployment_list="worker-generate"
|
|
auto_migrations=true
|
|
;;
|
|
kol-assist-worker)
|
|
deployment_list="kol-assist-worker"
|
|
auto_migrations=true
|
|
;;
|
|
scheduler)
|
|
deployment_list="scheduler"
|
|
auto_migrations=true
|
|
;;
|
|
web)
|
|
deployment_list="frontend ops-web"
|
|
;;
|
|
backend)
|
|
deployment_list="${backend_images}"
|
|
auto_migrations=true
|
|
;;
|
|
all-apps)
|
|
deployment_list="${all_app_images}"
|
|
auto_migrations=true
|
|
;;
|
|
migrate)
|
|
deployment_list=""
|
|
auto_migrations=true
|
|
skip_deployments=true
|
|
;;
|
|
*)
|
|
echo "::error::Unsupported target: ${SELECTED_TARGET}"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
case "${REQUESTED_RUN_MIGRATIONS}" in
|
|
auto)
|
|
run_migrations="${auto_migrations}"
|
|
;;
|
|
yes)
|
|
run_migrations=true
|
|
;;
|
|
no)
|
|
if [ "${SELECTED_TARGET}" = "migrate" ]; then
|
|
echo "::error::target=migrate requires run_migrations=yes or auto"
|
|
exit 1
|
|
fi
|
|
run_migrations=false
|
|
;;
|
|
*)
|
|
echo "::error::run_migrations must be auto, yes, or no"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
image_list="${deployment_list}"
|
|
if [ "${run_migrations}" = "true" ]; then
|
|
case " ${image_list} " in
|
|
*" migrate "*) ;;
|
|
*) image_list="migrate ${image_list}" ;;
|
|
esac
|
|
fi
|
|
image_list="$(printf '%s' "${image_list}" | xargs)"
|
|
|
|
image_tag="${REQUESTED_IMAGE_TAG}"
|
|
if [ -z "${image_tag}" ]; then
|
|
image_tag="$(git rev-parse --short=8 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
|
|
|
|
{
|
|
echo "TARGET_PASSWORD<<__TARGET_PASSWORD__"
|
|
printf '%s\n' "${prod_ssh_password}"
|
|
echo "__TARGET_PASSWORD__"
|
|
echo "REGISTRY_PASSWORD<<__REGISTRY_PASSWORD__"
|
|
printf '%s\n' "${registry_password}"
|
|
echo "__REGISTRY_PASSWORD__"
|
|
echo "PROD_SSH_PASSWORD_INPUT="
|
|
echo "PROD_SSH_PASSWORD_SECRET="
|
|
echo "REGISTRY_PASSWORD_INPUT="
|
|
echo "REGISTRY_PASSWORD_SECRET="
|
|
echo "PROD_HOST=${PROD_HOST}"
|
|
echo "TARGET_HOST=${PROD_USER}@${PROD_HOST}"
|
|
echo "TARGET_PORT=${PROD_PORT}"
|
|
echo "NAMESPACE=${K3S_NAMESPACE}"
|
|
echo "REGISTRY_HOST=${REGISTRY_HOST}"
|
|
echo "REGISTRY_OWNER=${REGISTRY_OWNER}"
|
|
echo "REGISTRY_IMAGE_PREFIX=${REGISTRY_IMAGE_PREFIX}"
|
|
echo "REGISTRY_USERNAME=${REGISTRY_USERNAME}"
|
|
echo "REGISTRY_PLAIN_HTTP=${REGISTRY_PLAIN_HTTP}"
|
|
echo "PULL_FROM_REGISTRY=true"
|
|
echo "IMAGE_TAG=${image_tag}"
|
|
echo "IMAGE_LIST=${image_list}"
|
|
echo "DEPLOYMENT_LIST=${deployment_list}"
|
|
echo "RUN_MIGRATIONS=${run_migrations}"
|
|
echo "SKIP_DEPLOYMENTS=${skip_deployments}"
|
|
echo "WAIT_TIMEOUT=${WAIT_TIMEOUT}"
|
|
} >> "${GITHUB_ENV}"
|
|
|
|
echo "Selected target: ${SELECTED_TARGET}"
|
|
echo "Deployments to roll out: ${deployment_list:-none}"
|
|
echo "Images to verify/import: ${image_list:-none}"
|
|
echo "Run migrations: ${run_migrations}"
|
|
echo "Image tag: ${image_tag}"
|
|
echo "Production target: ${PROD_USER}@${PROD_HOST}:${PROD_PORT}"
|
|
echo "Gitea Registry: ${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}/<service>:${image_tag}"
|
|
|
|
- name: Verify selected images in Gitea Registry
|
|
run: |
|
|
set -eu
|
|
|
|
if [ -z "${IMAGE_LIST}" ]; then
|
|
echo "No images selected; skipping registry verification."
|
|
exit 0
|
|
fi
|
|
|
|
if ! bash deploy/scripts/gitea-registry-images.sh verify; then
|
|
echo "::error::Missing image(s) at tag '${IMAGE_TAG}' for: ${IMAGE_LIST}."
|
|
echo "::error::Run Backend CI and/or Frontend CI manually first for the same commit/tag."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Prepare production SSH known host
|
|
run: |
|
|
set -eu
|
|
mkdir -p ~/.ssh
|
|
chmod 700 ~/.ssh
|
|
ssh-keyscan -p "${TARGET_PORT}" "${PROD_HOST}" >> ~/.ssh/known_hosts
|
|
|
|
- name: Import images and roll production k3s
|
|
run: |
|
|
set -eu
|
|
|
|
echo "Deploying ${DEPLOYMENT_LIST:-migration only} with tag ${IMAGE_TAG}..."
|
|
# Intentional word splitting: DEPLOYMENT_LIST is validated service names.
|
|
bash deploy/scripts/deploy-images-to-k3s.sh ${DEPLOYMENT_LIST}
|