From 0059212d68e319e38e56e85fda4fe904f13e8ceb Mon Sep 17 00:00:00 2001 From: liangxu Date: Sun, 31 May 2026 19:48:20 +0800 Subject: [PATCH] feat(deploy): add production deployment workflow and image deployment script --- .gitea/workflows/deploy-config-ci.yml | 2 +- .gitea/workflows/prod-k3s-deploy.yml | 442 +++++++++++++++++++++++++ deploy/scripts/deploy-images-to-k3s.sh | 420 +++++++++++++++++++++++ 3 files changed, 863 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/prod-k3s-deploy.yml create mode 100755 deploy/scripts/deploy-images-to-k3s.sh diff --git a/.gitea/workflows/deploy-config-ci.yml b/.gitea/workflows/deploy-config-ci.yml index 5c68c92..42032d6 100644 --- a/.gitea/workflows/deploy-config-ci.yml +++ b/.gitea/workflows/deploy-config-ci.yml @@ -72,4 +72,4 @@ jobs: docker image rm geo-rankly/tenant-api:ci-smoke >/dev/null 2>&1 || true - name: Validate shell scripts - run: bash -n deploy/scripts/package.sh deploy/scripts/load-and-start.sh deploy/scripts/gitea-registry-images.sh + run: bash -n deploy/scripts/package.sh deploy/scripts/load-and-start.sh deploy/scripts/gitea-registry-images.sh deploy/scripts/deploy-images-to-k3s.sh diff --git a/.gitea/workflows/prod-k3s-deploy.yml b/.gitea/workflows/prod-k3s-deploy.yml new file mode 100644 index 0000000..6c23737 --- /dev/null +++ b/.gitea/workflows/prod-k3s-deploy.yml @@ -0,0 +1,442 @@ +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} diff --git a/deploy/scripts/deploy-images-to-k3s.sh b/deploy/scripts/deploy-images-to-k3s.sh new file mode 100755 index 0000000..1cff998 --- /dev/null +++ b/deploy/scripts/deploy-images-to-k3s.sh @@ -0,0 +1,420 @@ +#!/usr/bin/env bash +# Deploy already-built images to a remote k3s host. +# +# The script can either use local Docker images or pull images from the Gitea +# registry, retag them as geo-rankly/:, copy a Docker archive to +# the production host, import it into k3s containerd, and roll deployments. +# +# Example: +# IMAGE_TAG=20260531-abcdef12 \ +# PULL_FROM_REGISTRY=true REGISTRY_PASSWORD=... \ +# TARGET_PASSWORD=... \ +# bash deploy/scripts/deploy-images-to-k3s.sh ops-api ops-web frontend + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TARGET_HOST="${TARGET_HOST:-root@39.105.229.239}" +TARGET_PORT="${TARGET_PORT:-22}" +TARGET_PASSWORD="${TARGET_PASSWORD:-}" +NAMESPACE="${NAMESPACE:-geo-rankly}" +LOCAL_PREFIX="${LOCAL_PREFIX:-geo-rankly}" +IMAGE_TAG="${IMAGE_TAG:-}" +REMOTE_DIR="${REMOTE_DIR:-/opt/geo-rankly/releases}" +WAIT_TIMEOUT="${WAIT_TIMEOUT:-10m}" +DELETE_REMOTE_TAR="${DELETE_REMOTE_TAR:-true}" +PULL_FROM_REGISTRY="${PULL_FROM_REGISTRY:-false}" +REGISTRY_HOST="${REGISTRY_HOST:-192.168.100.19:13000}" +REGISTRY_HOST="${REGISTRY_HOST#http://}" +REGISTRY_HOST="${REGISTRY_HOST#https://}" +REGISTRY_HOST="${REGISTRY_HOST%/}" +REGISTRY_OWNER="${REGISTRY_OWNER:-root}" +REGISTRY_IMAGE_PREFIX="${REGISTRY_IMAGE_PREFIX:-geo-rankly}" +REGISTRY_USERNAME="${REGISTRY_USERNAME:-root}" +REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-}" +REGISTRY_PLAIN_HTTP="${REGISTRY_PLAIN_HTTP:-true}" +RUN_MIGRATIONS="${RUN_MIGRATIONS:-false}" +SKIP_DEPLOYMENTS="${SKIP_DEPLOYMENTS:-false}" +TARGET_PLATFORM="${TARGET_PLATFORM:-}" +CLEAN_LOCAL_IMAGES="${CLEAN_LOCAL_IMAGES:-false}" + +DEFAULT_SERVICES=( + tenant-api + browser-fetch + ops-api + worker-generate + kol-assist-worker + scheduler + frontend + ops-web +) + +DEPLOYABLE_SERVICES=( + tenant-api + browser-fetch + ops-api + worker-generate + kol-assist-worker + scheduler + frontend + ops-web +) + +if [[ -z "${IMAGE_TAG}" ]]; then + echo "IMAGE_TAG is required. Use a unique tag, for example: IMAGE_TAG=$(git -C "${REPO_ROOT}" rev-parse --short=8 HEAD)-$(date +%Y%m%d%H%M%S)" >&2 + exit 1 +fi + +if [[ "$#" -gt 0 ]]; then + SERVICES=("$@") +elif [[ "${SKIP_DEPLOYMENTS}" == "true" ]]; then + SERVICES=() +else + SERVICES=("${DEFAULT_SERVICES[@]}") +fi + +case "${IMAGE_TAG}" in + *[!A-Za-z0-9_.-]*|"") + echo "IMAGE_TAG may only contain letters, numbers, underscore, dot, and dash." >&2 + exit 1 + ;; +esac + +case "${TARGET_PORT}" in + *[!0-9]*|"") + echo "TARGET_PORT must be numeric." >&2 + exit 1 + ;; +esac + +case "${TARGET_HOST}" in + *[!A-Za-z0-9_.@:-]*|"") + echo "TARGET_HOST must be user@host or host without spaces/shell characters." >&2 + exit 1 + ;; +esac + +case "${REMOTE_DIR}" in + /*) ;; + *) + echo "REMOTE_DIR must be an absolute path." >&2 + exit 1 + ;; +esac + +case "${REMOTE_DIR}" in + *[!A-Za-z0-9_./-]*) + echo "REMOTE_DIR contains unsupported characters." >&2 + exit 1 + ;; +esac + +case "${NAMESPACE}" in + *[!A-Za-z0-9_.-]*|"") + echo "NAMESPACE contains unsupported characters." >&2 + exit 1 + ;; +esac + +case "${LOCAL_PREFIX}" in + *[!A-Za-z0-9_./-]*|"") + echo "LOCAL_PREFIX contains unsupported characters." >&2 + exit 1 + ;; +esac + +case "${PULL_FROM_REGISTRY}" in + true|false) ;; + *) + echo "PULL_FROM_REGISTRY must be true or false." >&2 + exit 1 + ;; +esac + +case "${RUN_MIGRATIONS}" in + true|false) ;; + *) + echo "RUN_MIGRATIONS must be true or false." >&2 + exit 1 + ;; +esac + +case "${SKIP_DEPLOYMENTS}" in + true|false) ;; + *) + echo "SKIP_DEPLOYMENTS must be true or false." >&2 + exit 1 + ;; +esac + +case "${DELETE_REMOTE_TAR}" in + true|false) ;; + *) + echo "DELETE_REMOTE_TAR must be true or false." >&2 + exit 1 + ;; +esac + +case "${WAIT_TIMEOUT}" in + *[!A-Za-z0-9_.-]*|"") + echo "WAIT_TIMEOUT contains unsupported characters." >&2 + exit 1 + ;; +esac + +for svc in "${SERVICES[@]}"; do + supported=false + for known in "${DEPLOYABLE_SERVICES[@]}"; do + if [[ "${svc}" == "${known}" ]]; then + supported=true + break + fi + done + if [[ "${supported}" != "true" ]]; then + echo "Unsupported service ${svc}. Pass deployment/container names only." >&2 + exit 1 + fi +done + +IMAGE_SERVICES=("${SERVICES[@]}") +if [[ "${RUN_MIGRATIONS}" == "true" ]]; then + IMAGE_SERVICES=(migrate "${IMAGE_SERVICES[@]}") +fi + +if [[ "${#IMAGE_SERVICES[@]}" -eq 0 ]]; then + echo "No images selected. Pass services or set RUN_MIGRATIONS=true." >&2 + exit 1 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "docker is required on the NAS/build/Gitea runner host." >&2 + exit 1 +fi + +if [[ -n "${TARGET_PASSWORD}" ]] && ! command -v sshpass >/dev/null 2>&1; then + echo "sshpass is required when TARGET_PASSWORD is set." >&2 + exit 1 +fi + +registry_image() { + local svc="$1" + printf '%s/%s/%s/%s:%s' \ + "${REGISTRY_HOST}" \ + "${REGISTRY_OWNER}" \ + "${REGISTRY_IMAGE_PREFIX}" \ + "${svc}" \ + "${IMAGE_TAG}" +} + +local_image() { + local svc="$1" + printf '%s/%s:%s' "${LOCAL_PREFIX}" "${svc}" "${IMAGE_TAG}" +} + +if [[ "${PULL_FROM_REGISTRY}" == "true" ]]; then + for value_name in REGISTRY_HOST REGISTRY_OWNER REGISTRY_IMAGE_PREFIX REGISTRY_USERNAME; do + value="${!value_name}" + if [[ -z "${value}" ]]; then + echo "${value_name} is required when PULL_FROM_REGISTRY=true." >&2 + exit 1 + fi + done + + if [[ -z "${REGISTRY_PASSWORD}" ]]; then + echo "REGISTRY_PASSWORD is required when PULL_FROM_REGISTRY=true." >&2 + exit 1 + fi + + case "${REGISTRY_PLAIN_HTTP}" in + true|false) ;; + *) + echo "REGISTRY_PLAIN_HTTP must be true or false." >&2 + exit 1 + ;; + esac + + if [[ "${REGISTRY_PLAIN_HTTP}" == "true" ]] \ + && ! docker info 2>/dev/null \ + | sed -n '/Insecure Registries:/,/Live Restore/p' \ + | grep -Fq "${REGISTRY_HOST}"; then + echo "${REGISTRY_HOST} is not listed in Docker insecure registries." >&2 + echo "Configure the Gitea runner Docker daemon with: {\"insecure-registries\":[\"${REGISTRY_HOST}\"]}" >&2 + exit 1 + fi + + printf '%s\n' "${REGISTRY_PASSWORD}" \ + | docker login "${REGISTRY_HOST}" -u "${REGISTRY_USERNAME}" --password-stdin + + for svc in "${IMAGE_SERVICES[@]}"; do + src="$(registry_image "${svc}")" + dst="$(local_image "${svc}")" + echo "==> Pulling ${src}" + if [[ -n "${TARGET_PLATFORM}" ]]; then + docker pull --platform "${TARGET_PLATFORM}" "${src}" + else + docker pull "${src}" + fi + docker tag "${src}" "${dst}" + done +fi + +images=() +for svc in "${IMAGE_SERVICES[@]}"; do + image="$(local_image "${svc}")" + if ! docker image inspect "${image}" >/dev/null 2>&1; then + echo "Missing local image: ${image}" >&2 + echo "Build it first, or run with PULL_FROM_REGISTRY=true and a tag that exists in Gitea." >&2 + exit 1 + fi + images+=("${image}") +done + +safe_tag="$(printf '%s' "${IMAGE_TAG}" | tr -c 'A-Za-z0-9_.-' '-')" +archive="/tmp/geo-rankly-images-${safe_tag}.tar" +remote_archive="${REMOTE_DIR}/geo-rankly-images-${safe_tag}.tar" +service_list="${SERVICES[*]}" + +echo "==> Saving images:" +printf ' %s\n' "${images[@]}" +docker save "${images[@]}" -o "${archive}" +echo " archive: ${archive} ($(du -sh "${archive}" | cut -f1))" + +ssh_cmd=(ssh -p "${TARGET_PORT}" -o BatchMode=no -o StrictHostKeyChecking=accept-new) +scp_cmd=(scp -P "${TARGET_PORT}" -o BatchMode=no -o StrictHostKeyChecking=accept-new) + +remote_ssh() { + if [[ -n "${TARGET_PASSWORD}" ]]; then + SSHPASS="${TARGET_PASSWORD}" sshpass -e "${ssh_cmd[@]}" "${TARGET_HOST}" "$@" + else + "${ssh_cmd[@]}" "${TARGET_HOST}" "$@" + fi +} + +remote_scp() { + local source="$1" + local target="$2" + if [[ -n "${TARGET_PASSWORD}" ]]; then + SSHPASS="${TARGET_PASSWORD}" sshpass -e "${scp_cmd[@]}" "${source}" "${TARGET_HOST}:${target}" + else + "${scp_cmd[@]}" "${source}" "${TARGET_HOST}:${target}" + fi +} + +echo "==> Uploading archive to ${TARGET_HOST}:${remote_archive}" +remote_ssh "mkdir -p '${REMOTE_DIR}'" +remote_scp "${archive}" "${remote_archive}" + +echo "==> Importing images and rolling deployments on ${TARGET_HOST}" +remote_ssh \ + "NAMESPACE='${NAMESPACE}' LOCAL_PREFIX='${LOCAL_PREFIX}' IMAGE_TAG='${IMAGE_TAG}' SERVICES='${service_list}' REMOTE_ARCHIVE='${remote_archive}' WAIT_TIMEOUT='${WAIT_TIMEOUT}' DELETE_REMOTE_TAR='${DELETE_REMOTE_TAR}' RUN_MIGRATIONS='${RUN_MIGRATIONS}' bash -s" <<'REMOTE' +set -euo pipefail + +if ! command -v k3s >/dev/null 2>&1; then + echo "k3s is required on the target host." >&2 + exit 1 +fi + +kubectl_cmd() { + k3s kubectl "$@" +} + +echo " importing ${REMOTE_ARCHIVE}" +k3s ctr -n k8s.io images import "${REMOTE_ARCHIVE}" + +if [[ "${RUN_MIGRATIONS}" == "true" ]]; then + migrate_image="${LOCAL_PREFIX}/migrate:${IMAGE_TAG}" + echo " running migration job with ${migrate_image}" + kubectl_cmd -n "${NAMESPACE}" delete job migrate --ignore-not-found + cat < ${image}" + kubectl_cmd -n "${NAMESPACE}" set image "deployment/${svc}" "${svc}=${image}" + kubectl_cmd -n "${NAMESPACE}" rollout restart "deployment/${svc}" +done + +for svc in ${SERVICES}; do + echo " waiting deployment/${svc}" + kubectl_cmd -n "${NAMESPACE}" rollout status "deployment/${svc}" --timeout="${WAIT_TIMEOUT}" +done + +if [[ -n "${SERVICES}" ]]; then + kubectl_cmd -n "${NAMESPACE}" get deploy ${SERVICES} +fi + +if [[ "${DELETE_REMOTE_TAR}" == "true" ]]; then + rm -f "${REMOTE_ARCHIVE}" +fi +REMOTE + +rm -f "${archive}" + +if [[ "${CLEAN_LOCAL_IMAGES}" == "true" ]]; then + docker image rm "${images[@]}" >/dev/null 2>&1 || true +fi + +echo "" +echo "k3s image rollout complete: ${IMAGE_TAG}"