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.19:13000" registry_owner: description: "Gitea package owner" required: true default: "root" registry_image_prefix: description: "Image package prefix. Images are /." 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}/:${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}