diff --git a/.gitea/workflows/backend-ci.yml b/.gitea/workflows/backend-ci.yml index 223f599..1f21429 100644 --- a/.gitea/workflows/backend-ci.yml +++ b/.gitea/workflows/backend-ci.yml @@ -13,6 +13,8 @@ on: - "server/go.sum" - "server/Makefile" - "server/scripts/**" + - "server/Dockerfile" + - "server/.dockerignore" pull_request: branches: - main @@ -25,6 +27,8 @@ on: - "server/go.sum" - "server/Makefile" - "server/scripts/**" + - "server/Dockerfile" + - "server/.dockerignore" workflow_dispatch: permissions: @@ -68,3 +72,30 @@ jobs: - name: Build Go commands working-directory: server run: go build ./cmd/... + + - name: Install Docker CLI + if: github.event_name != 'pull_request' + run: | + if command -v docker >/dev/null 2>&1; then + docker --version + exit 0 + fi + apt-get update + apt-get install -y docker.io + + - name: Publish backend images + if: github.event_name != 'pull_request' + env: + REGISTRY_HOST: 192.168.100.19:13000 + REGISTRY_OWNER: root + REGISTRY_IMAGE_PREFIX: geo-rankly + REGISTRY_USERNAME: root + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + REGISTRY_PLAIN_HTTP: "true" + IMAGE_LIST: migrate tenant-api ops-api worker-generate kol-assist-worker scheduler + DELETE_OLD_IMAGE_TAGS: "true" + run: | + set -eu + IMAGE_TAG="$(git rev-parse --short=8 HEAD)" + export IMAGE_TAG + bash deploy/scripts/gitea-registry-images.sh ensure diff --git a/.gitea/workflows/deploy-config-ci.yml b/.gitea/workflows/deploy-config-ci.yml index 41b60e6..fb79005 100644 --- a/.gitea/workflows/deploy-config-ci.yml +++ b/.gitea/workflows/deploy-config-ci.yml @@ -58,4 +58,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 + run: bash -n deploy/scripts/package.sh deploy/scripts/load-and-start.sh deploy/scripts/gitea-registry-images.sh diff --git a/.gitea/workflows/frontend-ci.yml b/.gitea/workflows/frontend-ci.yml index de2f382..5bc4ccb 100644 --- a/.gitea/workflows/frontend-ci.yml +++ b/.gitea/workflows/frontend-ci.yml @@ -5,8 +5,12 @@ on: branches: - main paths: - - "apps/**" + - "apps/admin-web/**" + - "apps/ops-web/**" - "packages/**" + - "Dockerfile.frontend" + - "Dockerfile.ops-web" + - ".dockerignore" - "package.json" - "pnpm-lock.yaml" - "pnpm-workspace.yaml" @@ -14,8 +18,12 @@ on: branches: - main paths: - - "apps/**" + - "apps/admin-web/**" + - "apps/ops-web/**" - "packages/**" + - "Dockerfile.frontend" + - "Dockerfile.ops-web" + - ".dockerignore" - "package.json" - "pnpm-lock.yaml" - "pnpm-workspace.yaml" @@ -51,11 +59,35 @@ jobs: - name: Typecheck ops web run: pnpm --filter ops-web typecheck - - name: Typecheck browser extension - run: pnpm typecheck:extension - - name: Build admin web run: pnpm build:admin - name: Build ops web run: pnpm --filter ops-web build + + - name: Install Docker CLI + if: github.event_name != 'pull_request' + run: | + if command -v docker >/dev/null 2>&1; then + docker --version + exit 0 + fi + apt-get update + apt-get install -y docker.io + + - name: Publish frontend images + if: github.event_name != 'pull_request' + env: + REGISTRY_HOST: 192.168.100.19:13000 + REGISTRY_OWNER: root + REGISTRY_IMAGE_PREFIX: geo-rankly + REGISTRY_USERNAME: root + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + REGISTRY_PLAIN_HTTP: "true" + IMAGE_LIST: frontend ops-web + DELETE_OLD_IMAGE_TAGS: "true" + run: | + set -eu + IMAGE_TAG="$(git rev-parse --short=8 HEAD)" + export IMAGE_TAG + bash deploy/scripts/gitea-registry-images.sh ensure diff --git a/.gitea/workflows/nas-compose-deploy.yml b/.gitea/workflows/nas-compose-deploy.yml index 8b6c083..68736f0 100644 --- a/.gitea/workflows/nas-compose-deploy.yml +++ b/.gitea/workflows/nas-compose-deploy.yml @@ -31,8 +31,44 @@ on: description: "Base deployment directory on the NAS" required: true default: "/vol1/1000/application/geo" + registry_host: + description: "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 become /." + 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 is workflow_input." + required: false + default: "" + registry_plain_http: + description: "Registry uses plain HTTP and must be configured as an insecure registry." + required: true + default: "true" + type: choice + options: + - "true" + - "false" image_tag: - description: "Docker image tag. Defaults to the short commit SHA." + description: "Docker image tag. Defaults to the pushed commit SHA first 8 chars." required: false default: "" keep_releases: @@ -56,10 +92,19 @@ jobs: NAS_SSH_PASSWORD_INPUT: ${{ inputs.ssh_password }} NAS_SSH_PASSWORD_SECRET: ${{ secrets.NAS_SSH_PASSWORD }} DEPLOY_BASE: ${{ inputs.deploy_base }} + 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 }} REQUESTED_IMAGE_TAG: ${{ inputs.image_tag }} KEEP_RELEASES: ${{ inputs.keep_releases }} NAS_DEPLOY_ENV: ${{ secrets.NAS_DEPLOY_ENV }} DOCKER_BUILDKIT: "1" + IMAGE_LIST: migrate tenant-api ops-api worker-generate kol-assist-worker scheduler frontend ops-web steps: - name: Checkout @@ -68,7 +113,7 @@ jobs: - name: Install SSH tools run: | apt-get update - apt-get install -y openssh-client sshpass tar + apt-get install -y curl openssh-client sshpass tar - name: Resolve deployment metadata run: | @@ -108,6 +153,44 @@ jobs: 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 + + { + echo "REGISTRY_PASSWORD<<__REGISTRY_PASSWORD__" + printf '%s\n' "${registry_password}" + echo "__REGISTRY_PASSWORD__" + echo "REGISTRY_PASSWORD_INPUT=" + echo "REGISTRY_PASSWORD_SECRET=" + } >> "${GITHUB_ENV}" + + 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 + + REGISTRY_HOST="${REGISTRY_HOST#http://}" + REGISTRY_HOST="${REGISTRY_HOST#https://}" + REGISTRY_HOST="${REGISTRY_HOST%/}" + case "${NAS_PORT}" in *[!0-9]*|"") echo "::error::NAS SSH port must be numeric" @@ -134,9 +217,45 @@ jobs: *[!0-9]*|"") KEEP_RELEASES=5 ;; esac + 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 + image_tag="${REQUESTED_IMAGE_TAG}" if [ -z "${image_tag}" ]; then - image_tag="$(git rev-parse --short=12 HEAD)" + image_tag="$(git rev-parse --short=8 HEAD)" fi case "${image_tag}" in @@ -149,6 +268,7 @@ jobs: release_id="$(date -u +%Y%m%d%H%M%S)-$(git rev-parse --short=12 HEAD)" { + echo "REGISTRY_HOST=${REGISTRY_HOST}" echo "IMAGE_TAG=${image_tag}" echo "RELEASE_ID=${release_id}" echo "KEEP_RELEASES=${KEEP_RELEASES}" @@ -157,6 +277,15 @@ jobs: echo "Image tag: ${image_tag}" echo "Release ID: ${release_id}" echo "Deploy base: ${DEPLOY_BASE}" + echo "Gitea Registry: ${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}/:${image_tag}" + + - name: Verify current images in Gitea Registry + run: | + set -eu + if ! bash deploy/scripts/gitea-registry-images.sh verify; then + echo "::error::Missing image(s) for commit ${IMAGE_TAG}. CI must publish images before CD." + exit 1 + fi - name: Prepare SSH known hosts run: | @@ -205,12 +334,12 @@ jobs: 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/ + printf '%s\n' "${REGISTRY_PASSWORD}" \ + | docker login "${REGISTRY_HOST}" -u "${REGISTRY_USERNAME}" --password-stdin + + echo "Pulling migration image..." + docker pull "${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}/migrate:${IMAGE_TAG}" + docker tag "${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}/migrate:${IMAGE_TAG}" "${migrate_image}" 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" @@ -274,9 +403,11 @@ jobs: 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')" + registry_password_b64="$(printf '%s' "${REGISTRY_PASSWORD}" | base64 | tr -d '\n')" { printf 'SUDO_PASSWORD_B64=%s\n' "${sudo_password_b64}" + printf 'REGISTRY_PASSWORD_B64=%s\n' "${registry_password_b64}" cat <<'REMOTE_SCRIPT' set -eu @@ -297,7 +428,8 @@ jobs: fi SUDO_PASSWORD="$(printf '%s' "${SUDO_PASSWORD_B64}" | base64 -d 2>/dev/null || true)" - unset SUDO_PASSWORD_B64 + REGISTRY_PASSWORD="$(printf '%s' "${REGISTRY_PASSWORD_B64}" | base64 -d 2>/dev/null || true)" + unset SUDO_PASSWORD_B64 REGISTRY_PASSWORD_B64 docker_cmd() { if [ "${DOCKER_MODE}" = "sudo" ]; then @@ -311,6 +443,17 @@ jobs: docker_cmd compose "$@" } + docker_login() { + if [ "${DOCKER_MODE}" = "sudo" ]; then + printf '%s\n' "${SUDO_PASSWORD}" | sudo -S -p "" -v + printf '%s\n' "${REGISTRY_PASSWORD}" \ + | sudo -p "" docker login "${REGISTRY_HOST}" -u "${REGISTRY_USERNAME}" --password-stdin + else + printf '%s\n' "${REGISTRY_PASSWORD}" \ + | docker login "${REGISTRY_HOST}" -u "${REGISTRY_USERNAME}" --password-stdin + fi + } + 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 @@ -323,18 +466,38 @@ jobs: echo "Docker access mode: ${DOCKER_MODE}" compose_cmd version - IMAGE_TAG="${IMAGE_TAG}" DOCKER_BUILDKIT=1 compose_cmd \ + if [ "${REGISTRY_PLAIN_HTTP}" = "true" ] \ + && ! docker_cmd 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 on the NAS." + echo "Configure Docker daemon.json with: {\"insecure-registries\":[\"${REGISTRY_HOST}\"]}" + exit 1 + fi + + docker_login + + IMAGE_REGISTRY="${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}" \ + IMAGE_TAG="${IMAGE_TAG}" compose_cmd \ --env-file "${shared_dir}/.env" \ -f "${release_dir}/deploy/docker-compose.yaml" \ config >/tmp/geo-rankly-compose.yaml - IMAGE_TAG="${IMAGE_TAG}" DOCKER_BUILDKIT=1 compose_cmd \ + IMAGE_REGISTRY="${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}" \ + IMAGE_TAG="${IMAGE_TAG}" compose_cmd \ --env-file "${shared_dir}/.env" \ -f "${release_dir}/deploy/docker-compose.yaml" \ - up -d --build --remove-orphans + pull migrate tenant-api ops-api worker-generate kol-assist-worker scheduler frontend ops-web + + IMAGE_REGISTRY="${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}" \ + IMAGE_TAG="${IMAGE_TAG}" compose_cmd \ + --env-file "${shared_dir}/.env" \ + -f "${release_dir}/deploy/docker-compose.yaml" \ + up -d --no-build --remove-orphans ln -sfn "${release_dir}" "${DEPLOY_BASE}/current" + IMAGE_REGISTRY="${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}" \ IMAGE_TAG="${IMAGE_TAG}" compose_cmd \ --env-file "${shared_dir}/.env" \ -f "${release_dir}/deploy/docker-compose.yaml" \ @@ -348,7 +511,7 @@ jobs: 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}' KEEP_RELEASES='${KEEP_RELEASES}' sh -se" + "DEPLOY_BASE='${DEPLOY_BASE}' RELEASE_ID='${RELEASE_ID}' IMAGE_TAG='${IMAGE_TAG}' KEEP_RELEASES='${KEEP_RELEASES}' REGISTRY_HOST='${REGISTRY_HOST}' REGISTRY_OWNER='${REGISTRY_OWNER}' REGISTRY_IMAGE_PREFIX='${REGISTRY_IMAGE_PREFIX}' REGISTRY_USERNAME='${REGISTRY_USERNAME}' REGISTRY_PLAIN_HTTP='${REGISTRY_PLAIN_HTTP}' sh -se" - name: Deployment summary run: | @@ -360,6 +523,7 @@ jobs: echo "- Deploy base: ${DEPLOY_BASE}" echo "- Release: ${RELEASE_ID}" echo "- Image tag: ${IMAGE_TAG}" + echo "- Gitea Registry: ${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}/:${IMAGE_TAG}" echo "" echo "Useful NAS commands:" echo "" diff --git a/.gitea/workflows/nas-compose-service-deploy.yml b/.gitea/workflows/nas-compose-service-deploy.yml index f1b3543..6334613 100644 --- a/.gitea/workflows/nas-compose-service-deploy.yml +++ b/.gitea/workflows/nas-compose-service-deploy.yml @@ -64,8 +64,44 @@ on: description: "Base deployment directory on the NAS" required: true default: "/vol1/1000/application/geo" + registry_host: + description: "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 become /." + 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 is workflow_input." + required: false + default: "" + registry_plain_http: + description: "Registry uses plain HTTP and must be configured as an insecure registry." + required: true + default: "true" + type: choice + options: + - "true" + - "false" image_tag: - description: "Docker image tag. Defaults to the short commit SHA." + description: "Docker image tag. Defaults to the pushed commit SHA first 8 chars." required: false default: "" keep_releases: @@ -89,6 +125,14 @@ jobs: NAS_SSH_PASSWORD_INPUT: ${{ inputs.ssh_password }} NAS_SSH_PASSWORD_SECRET: ${{ secrets.NAS_SSH_PASSWORD }} DEPLOY_BASE: ${{ inputs.deploy_base }} + 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_SERVICE: ${{ inputs.service }} REQUESTED_IMAGE_TAG: ${{ inputs.image_tag }} REQUESTED_RUN_MIGRATIONS: ${{ inputs.run_migrations }} @@ -104,7 +148,7 @@ jobs: - name: Install SSH tools run: | apt-get update - apt-get install -y openssh-client sshpass tar + apt-get install -y curl openssh-client sshpass tar - name: Resolve deployment metadata run: | @@ -144,6 +188,44 @@ jobs: 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 + + { + echo "REGISTRY_PASSWORD<<__REGISTRY_PASSWORD__" + printf '%s\n' "${registry_password}" + echo "__REGISTRY_PASSWORD__" + echo "REGISTRY_PASSWORD_INPUT=" + echo "REGISTRY_PASSWORD_SECRET=" + } >> "${GITHUB_ENV}" + + 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 + + REGISTRY_HOST="${REGISTRY_HOST#http://}" + REGISTRY_HOST="${REGISTRY_HOST#https://}" + REGISTRY_HOST="${REGISTRY_HOST%/}" + case "${NAS_PORT}" in *[!0-9]*|"") echo "::error::NAS SSH port must be numeric" @@ -170,6 +252,42 @@ jobs: *[!0-9]*|"") KEEP_RELEASES=0 ;; esac + 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 + auto_migrations=false case "${SELECTED_SERVICE}" in frontend) @@ -241,7 +359,7 @@ jobs: image_tag="${REQUESTED_IMAGE_TAG}" if [ -z "${image_tag}" ]; then - image_tag="$(git rev-parse --short=12 HEAD)" + image_tag="$(git rev-parse --short=8 HEAD)" fi case "${image_tag}" in @@ -255,6 +373,7 @@ jobs: release_id="$(date -u +%Y%m%d%H%M%S)-$(git rev-parse --short=12 HEAD)-${service_slug}" { + echo "REGISTRY_HOST=${REGISTRY_HOST}" echo "IMAGE_TAG=${image_tag}" echo "RELEASE_ID=${release_id}" echo "SERVICE_LIST=${service_list}" @@ -269,6 +388,25 @@ jobs: echo "Image tag: ${image_tag}" echo "Release ID: ${release_id}" echo "Deploy base: ${DEPLOY_BASE}" + echo "Gitea Registry: ${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}/:${image_tag}" + + - name: Verify current images in Gitea Registry + run: | + set -eu + + image_list="${SERVICE_LIST}" + if [ "${RUN_MIGRATIONS}" = "true" ]; then + case " ${image_list} " in + *" migrate "*) ;; + *) image_list="migrate ${image_list}" ;; + esac + fi + + IMAGE_LIST="${image_list}" bash deploy/scripts/gitea-registry-images.sh verify || { + echo "::error::Missing image(s) for commit ${IMAGE_TAG}. CI must publish images before CD." + echo "::error::Frontend images come from Frontend CI; backend/migrate images come from Backend CI." + exit 1 + } - name: Prepare SSH known hosts run: | @@ -322,12 +460,12 @@ jobs: 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/ + printf '%s\n' "${REGISTRY_PASSWORD}" \ + | docker login "${REGISTRY_HOST}" -u "${REGISTRY_USERNAME}" --password-stdin + + echo "Pulling migration image..." + docker pull "${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}/migrate:${IMAGE_TAG}" + docker tag "${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}/migrate:${IMAGE_TAG}" "${migrate_image}" 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" @@ -391,9 +529,11 @@ jobs: 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')" + registry_password_b64="$(printf '%s' "${REGISTRY_PASSWORD}" | base64 | tr -d '\n')" { printf 'SUDO_PASSWORD_B64=%s\n' "${sudo_password_b64}" + printf 'REGISTRY_PASSWORD_B64=%s\n' "${registry_password_b64}" cat <<'REMOTE_SCRIPT' set -eu @@ -414,7 +554,8 @@ jobs: fi SUDO_PASSWORD="$(printf '%s' "${SUDO_PASSWORD_B64}" | base64 -d 2>/dev/null || true)" - unset SUDO_PASSWORD_B64 + REGISTRY_PASSWORD="$(printf '%s' "${REGISTRY_PASSWORD_B64}" | base64 -d 2>/dev/null || true)" + unset SUDO_PASSWORD_B64 REGISTRY_PASSWORD_B64 docker_cmd() { if [ "${DOCKER_MODE}" = "sudo" ]; then @@ -428,6 +569,17 @@ jobs: docker_cmd compose "$@" } + docker_login() { + if [ "${DOCKER_MODE}" = "sudo" ]; then + printf '%s\n' "${SUDO_PASSWORD}" | sudo -S -p "" -v + printf '%s\n' "${REGISTRY_PASSWORD}" \ + | sudo -p "" docker login "${REGISTRY_HOST}" -u "${REGISTRY_USERNAME}" --password-stdin + else + printf '%s\n' "${REGISTRY_PASSWORD}" \ + | docker login "${REGISTRY_HOST}" -u "${REGISTRY_USERNAME}" --password-stdin + fi + } + 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 @@ -440,38 +592,61 @@ jobs: echo "Docker access mode: ${DOCKER_MODE}" compose_cmd version - IMAGE_TAG="${IMAGE_TAG}" DOCKER_BUILDKIT=1 compose_cmd \ + if [ "${REGISTRY_PLAIN_HTTP}" = "true" ] \ + && ! docker_cmd 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 on the NAS." + echo "Configure Docker daemon.json with: {\"insecure-registries\":[\"${REGISTRY_HOST}\"]}" + exit 1 + fi + + docker_login + + IMAGE_REGISTRY="${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}" \ + IMAGE_TAG="${IMAGE_TAG}" 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_REGISTRY="${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}" \ 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 \ + IMAGE_REGISTRY="${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}" \ + IMAGE_TAG="${IMAGE_TAG}" compose_cmd \ --env-file "${shared_dir}/.env" \ -f "${release_dir}/deploy/docker-compose.yaml" \ - up --build --force-recreate migrate + pull migrate + + IMAGE_REGISTRY="${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}" \ + IMAGE_TAG="${IMAGE_TAG}" compose_cmd \ + --env-file "${shared_dir}/.env" \ + -f "${release_dir}/deploy/docker-compose.yaml" \ + up --no-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}" + echo "Pulling selected service image(s): ${SERVICE_LIST}" + IMAGE_REGISTRY="${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}" \ 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} + pull ${SERVICE_LIST} + + echo "Restarting selected service(s): ${SERVICE_LIST}" + IMAGE_REGISTRY="${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}" \ + IMAGE_TAG="${IMAGE_TAG}" compose_cmd \ + --env-file "${shared_dir}/.env" \ + -f "${release_dir}/deploy/docker-compose.yaml" \ + up -d --no-build --no-deps --force-recreate ${SERVICE_LIST} ln -sfn "${release_dir}" "${DEPLOY_BASE}/current" + IMAGE_REGISTRY="${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}" \ IMAGE_TAG="${IMAGE_TAG}" compose_cmd \ --env-file "${shared_dir}/.env" \ -f "${release_dir}/deploy/docker-compose.yaml" \ @@ -487,7 +662,7 @@ jobs: 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" + "DEPLOY_BASE='${DEPLOY_BASE}' RELEASE_ID='${RELEASE_ID}' IMAGE_TAG='${IMAGE_TAG}' SERVICE_LIST='${SERVICE_LIST}' RUN_MIGRATIONS='${RUN_MIGRATIONS}' KEEP_RELEASES='${KEEP_RELEASES}' REGISTRY_HOST='${REGISTRY_HOST}' REGISTRY_OWNER='${REGISTRY_OWNER}' REGISTRY_IMAGE_PREFIX='${REGISTRY_IMAGE_PREFIX}' REGISTRY_USERNAME='${REGISTRY_USERNAME}' REGISTRY_PLAIN_HTTP='${REGISTRY_PLAIN_HTTP}' sh -se" - name: Deployment summary run: | @@ -499,6 +674,7 @@ jobs: echo "- Deploy base: ${DEPLOY_BASE}" echo "- Release: ${RELEASE_ID}" echo "- Image tag: ${IMAGE_TAG}" + echo "- Gitea Registry: ${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}/:${IMAGE_TAG}" echo "- Selected input: ${SELECTED_SERVICE}" echo "- Compose service(s): ${SERVICE_LIST}" echo "- Run migrations: ${RUN_MIGRATIONS}" diff --git a/.gitea/workflows/nas-k3s-deploy.yml b/.gitea/workflows/nas-k3s-deploy.yml new file mode 100644 index 0000000..62cfb53 --- /dev/null +++ b/.gitea/workflows/nas-k3s-deploy.yml @@ -0,0 +1,947 @@ +name: Deploy NAS K3s + +"on": + workflow_dispatch: + inputs: + target: + description: "Service, group, full deploy, or manifest 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 + - all + - migrate + - manifest + manifest_path: + description: "deploy/k3s yaml to apply when target=manifest, e.g. ingress.yaml or config/ops-config.yaml" + required: false + default: "" + run_migrations: + description: "Run database migrations before app rollout" + 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-k3s" + namespace: + description: "Kubernetes namespace" + required: true + default: "geo-rankly" + registry_host: + description: "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 become /." + 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 is workflow_input." + required: false + default: "" + registry_plain_http: + description: "Registry uses plain HTTP and must be configured as an insecure registry." + required: true + default: "true" + type: choice + options: + - "true" + - "false" + image_tag: + description: "Docker image tag. Defaults to the pushed commit SHA first 8 chars." + 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-k3s: + name: Deploy selected k3s target 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 }} + 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 }} + MANIFEST_PATH: ${{ inputs.manifest_path }} + REQUESTED_IMAGE_TAG: ${{ inputs.image_tag }} + REQUESTED_RUN_MIGRATIONS: ${{ inputs.run_migrations }} + VALIDATE_MIGRATIONS: ${{ inputs.validate_migrations }} + KEEP_RELEASES: ${{ inputs.keep_releases }} + DOCKER_BUILDKIT: "1" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install SSH tools + run: | + apt-get update + apt-get install -y curl 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 "${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 + + { + echo "REGISTRY_PASSWORD<<__REGISTRY_PASSWORD__" + printf '%s\n' "${registry_password}" + echo "__REGISTRY_PASSWORD__" + echo "REGISTRY_PASSWORD_INPUT=" + echo "REGISTRY_PASSWORD_SECRET=" + } >> "${GITHUB_ENV}" + + REGISTRY_HOST="${REGISTRY_HOST#http://}" + REGISTRY_HOST="${REGISTRY_HOST#https://}" + REGISTRY_HOST="${REGISTRY_HOST%/}" + + 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 "${K3S_NAMESPACE}" in + *[!A-Za-z0-9_.-]*|"") + echo "::error::namespace contains unsupported characters" + exit 1 + ;; + esac + + 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 "${KEEP_RELEASES}" in + *[!0-9]*|"") KEEP_RELEASES=0 ;; + esac + + all_app_images="tenant-api ops-api worker-generate kol-assist-worker scheduler frontend ops-web" + backend_images="tenant-api ops-api worker-generate kol-assist-worker scheduler" + image_list="" + deployment_list="" + apply_kustomize=false + auto_migrations=false + + case "${SELECTED_TARGET}" in + frontend) + image_list="frontend" + deployment_list="frontend" + ;; + ops-web) + image_list="ops-web" + deployment_list="ops-web" + ;; + tenant-api) + image_list="tenant-api" + deployment_list="tenant-api" + auto_migrations=true + ;; + ops-api) + image_list="ops-api" + deployment_list="ops-api" + auto_migrations=true + ;; + worker-generate) + image_list="worker-generate" + deployment_list="worker-generate" + auto_migrations=true + ;; + kol-assist-worker) + image_list="kol-assist-worker" + deployment_list="kol-assist-worker" + auto_migrations=true + ;; + scheduler) + image_list="scheduler" + deployment_list="scheduler" + auto_migrations=true + ;; + web) + image_list="frontend ops-web" + deployment_list="frontend ops-web" + ;; + backend) + image_list="${backend_images}" + deployment_list="${backend_images}" + auto_migrations=true + ;; + all-apps) + image_list="${all_app_images}" + deployment_list="${all_app_images}" + auto_migrations=true + ;; + all) + image_list="${all_app_images}" + deployment_list="${all_app_images}" + apply_kustomize=true + auto_migrations=true + ;; + migrate) + image_list="migrate" + auto_migrations=true + ;; + manifest) + manifest_input="${MANIFEST_PATH#./}" + manifest_input="${manifest_input#deploy/k3s/}" + manifest_input="${manifest_input%/}" + if [ -z "${manifest_input}" ]; then + echo "::error::manifest_path is required when target=manifest" + exit 1 + fi + case "${manifest_input}" in + /*|*..*|*" "*) + echo "::error::manifest_path must be a relative path under deploy/k3s without '..' or spaces" + exit 1 + ;; + esac + case "${manifest_input}" in + *[!A-Za-z0-9_./-]*) + echo "::error::manifest_path contains unsupported characters" + exit 1 + ;; + esac + case "${manifest_input}" in + apps.yaml|apps.yml) + image_list="${all_app_images}" + deployment_list="${all_app_images}" + ;; + jobs.yaml|jobs.yml) + image_list="migrate" + ;; + kustomization.yaml|kustomization.yml) + image_list="${all_app_images}" + deployment_list="${all_app_images}" + ;; + esac + MANIFEST_PATH="${manifest_input}" + ;; + *) + echo "::error::Unsupported target: ${SELECTED_TARGET}" + 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 + + if [ "${run_migrations}" = "true" ]; then + case " ${image_list} " in + *" migrate "*) ;; + *) image_list="migrate ${image_list}" ;; + esac + fi + + case "${VALIDATE_MIGRATIONS}" in + true|false) ;; + *) + echo "::error::validate_migrations must be true or false" + exit 1 + ;; + esac + + if [ -n "${image_list}" ] && [ -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 + + 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 + + target_slug="$(printf '%s' "${SELECTED_TARGET}" | tr -c 'A-Za-z0-9_.-' '-')" + release_id="$(date -u +%Y%m%d%H%M%S)-$(git rev-parse --short=12 HEAD)-k3s-${target_slug}" + + { + echo "REGISTRY_HOST=${REGISTRY_HOST}" + echo "REGISTRY_PLAIN_HTTP=${REGISTRY_PLAIN_HTTP}" + echo "IMAGE_TAG=${image_tag}" + echo "RELEASE_ID=${release_id}" + echo "IMAGE_LIST=${image_list}" + echo "DEPLOYMENT_LIST=${deployment_list}" + echo "APPLY_KUSTOMIZE=${apply_kustomize}" + echo "RUN_MIGRATIONS=${run_migrations}" + echo "MANIFEST_PATH=${MANIFEST_PATH}" + echo "KEEP_RELEASES=${KEEP_RELEASES}" + } >> "${GITHUB_ENV}" + + echo "Selected target: ${SELECTED_TARGET}" + echo "Manifest path: ${MANIFEST_PATH:-}" + echo "Images to verify/deploy: ${image_list:-none}" + echo "Deployments to roll out: ${deployment_list:-none}" + echo "Apply full kustomize: ${apply_kustomize}" + 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}" + echo "Gitea Registry: ${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}/:${image_tag}" + + - name: Verify current 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) for commit ${IMAGE_TAG}. CI must publish images before CD." + echo "::error::Frontend images come from Frontend CI; backend/migrate images come from Backend CI." + exit 1 + fi + + - 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-k3s-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 + + printf '%s\n' "${REGISTRY_PASSWORD}" \ + | docker login "${REGISTRY_HOST}" -u "${REGISTRY_USERNAME}" --password-stdin + + echo "Pulling migration image..." + docker pull "${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}/migrate:${IMAGE_TAG}" + docker tag "${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}/migrate:${IMAGE_TAG}" "${migrate_image}" + + 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: Prepare SSH known hosts + run: | + set -eu + mkdir -p ~/.ssh + chmod 700 ~/.ssh + ssh-keyscan -p "${NAS_PORT}" "${NAS_HOST}" >> ~/.ssh/known_hosts + + - 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: Deploy selected target with k3s + 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')" + registry_password_b64="$(printf '%s' "${REGISTRY_PASSWORD:-}" | base64 | tr -d '\n')" + + { + printf 'SUDO_PASSWORD_B64=%s\n' "${sudo_password_b64}" + printf 'REGISTRY_PASSWORD_B64=%s\n' "${registry_password_b64}" + cat <<'REMOTE_SCRIPT' + set -eu + + release_dir="${DEPLOY_BASE}/releases/${RELEASE_ID}" + lock_dir="${DEPLOY_BASE}/.deploy.lock" + rendered_dir="${release_dir}/.k3s-rendered" + + 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 + + SUDO_PASSWORD="$(printf '%s' "${SUDO_PASSWORD_B64}" | base64 -d 2>/dev/null || true)" + REGISTRY_PASSWORD="$(printf '%s' "${REGISTRY_PASSWORD_B64}" | base64 -d 2>/dev/null || true)" + unset SUDO_PASSWORD_B64 REGISTRY_PASSWORD_B64 + + if command -v sudo >/dev/null 2>&1 && [ -n "${SUDO_PASSWORD}" ] && printf '%s\n' "${SUDO_PASSWORD}" | sudo -S -p "" true >/dev/null 2>&1; then + SUDO_MODE="sudo" + else + SUDO_MODE="direct" + fi + + sudo_cmd() { + if [ "${SUDO_MODE}" = "sudo" ]; then + printf '%s\n' "${SUDO_PASSWORD}" | sudo -S -p "" "$@" + else + "$@" + fi + } + + kubectl_cmd() { + if command -v kubectl >/dev/null 2>&1; then + kubectl "$@" + elif command -v k3s >/dev/null 2>&1; then + sudo_cmd k3s kubectl "$@" + else + echo "Cannot find kubectl or k3s on NAS." + exit 1 + fi + } + + image_name() { + printf '%s/%s/%s/%s' \ + "${REGISTRY_HOST}" \ + "${REGISTRY_OWNER}" \ + "${REGISTRY_IMAGE_PREFIX}" \ + "$1" + } + + image_ref() { + printf '%s:%s' "$(image_name "$1")" "${IMAGE_TAG}" + } + + echo "Sudo mode: ${SUDO_MODE}" + kubectl_cmd version --client + + check_plain_http_registry() { + if [ "${REGISTRY_PLAIN_HTTP}" != "true" ]; then + return 0 + fi + + registry_file="/etc/rancher/k3s/registries.yaml" + if ! sudo_cmd test -f "${registry_file}"; then + echo "Gitea Registry is configured as plain HTTP, but ${registry_file} does not exist." + echo "Create it on the NAS before deployment:" + echo "mirrors:" + echo " \"${REGISTRY_HOST}\":" + echo " endpoint:" + echo " - \"http://${REGISTRY_HOST}\"" + echo "Then restart k3s." + exit 1 + fi + + if ! sudo_cmd grep -F "http://${REGISTRY_HOST}" "${registry_file}" >/dev/null 2>&1; then + echo "Gitea Registry is configured as plain HTTP, but ${registry_file} does not contain http://${REGISTRY_HOST}." + echo "Add this mirror endpoint and restart k3s:" + echo "mirrors:" + echo " \"${REGISTRY_HOST}\":" + echo " endpoint:" + echo " - \"http://${REGISTRY_HOST}\"" + exit 1 + fi + } + + ensure_namespace() { + kubectl_cmd create namespace "${K3S_NAMESPACE}" --dry-run=client -o yaml \ + | kubectl_cmd apply -f - + } + + ensure_registry_secret() { + if [ -z "${REGISTRY_PASSWORD}" ]; then + echo "Registry password was not provided; keeping existing k3s imagePullSecrets." + return 0 + fi + + kubectl_cmd -n "${K3S_NAMESPACE}" create secret docker-registry gitea-registry \ + --docker-server="${REGISTRY_HOST}" \ + --docker-username="${REGISTRY_USERNAME}" \ + --docker-password="${REGISTRY_PASSWORD}" \ + --dry-run=client -o yaml \ + | kubectl_cmd -n "${K3S_NAMESPACE}" apply -f - + + kubectl_cmd -n "${K3S_NAMESPACE}" patch serviceaccount default \ + --type=merge \ + -p '{"imagePullSecrets":[{"name":"gitea-registry"}]}' >/dev/null + } + + patch_kustomize_image() { + old_name="$1" + svc="$2" + new_name="$(image_name "${svc}")" + sed -i "\#name: ${old_name}#,\#newTag:# s#newName: .*#newName: ${new_name}#" "${rendered_dir}/kustomization.yaml" + sed -i "\#name: ${old_name}#,\#newTag:# s#newTag: .*#newTag: ${IMAGE_TAG}#" "${rendered_dir}/kustomization.yaml" + } + + render_kustomize() { + rm -rf "${rendered_dir}" + mkdir -p "${rendered_dir}" + cp -R "${release_dir}/deploy/k3s/." "${rendered_dir}/" + + if grep -q '^namespace:' "${rendered_dir}/kustomization.yaml"; then + sed -i "s#^namespace:.*#namespace: ${K3S_NAMESPACE}#" "${rendered_dir}/kustomization.yaml" + fi + + # Migrations are applied explicitly by this workflow after preflight. + sed -i '/^[[:space:]]*- jobs.yaml[[:space:]]*$/d' "${rendered_dir}/kustomization.yaml" + + patch_kustomize_image "geo-rankly/migrate" "migrate" + patch_kustomize_image "geo-rankly/tenant-api" "tenant-api" + patch_kustomize_image "geo-rankly/worker-generate" "worker-generate" + patch_kustomize_image "geo-rankly/kol-assist-worker" "kol-assist-worker" + patch_kustomize_image "geo-rankly/scheduler" "scheduler" + patch_kustomize_image "geo-rankly/ops-api" "ops-api" + patch_kustomize_image "geo-rankly/frontend" "frontend" + patch_kustomize_image "geo-rankly/ops-web" "ops-web" + } + + apply_rendered_kustomize() { + render_kustomize + kubectl_cmd apply -k "${rendered_dir}" + } + + apply_rendered_infra_kustomize() { + render_kustomize + sed -i '/^[[:space:]]*- apps.yaml[[:space:]]*$/d' "${rendered_dir}/kustomization.yaml" + kubectl_cmd apply -k "${rendered_dir}" + } + + apply_config_map() { + kubectl_cmd -n "${K3S_NAMESPACE}" create configmap geo-rankly-app-config \ + --from-file=config.yaml="${release_dir}/deploy/k3s/config/config.yaml" \ + --from-file=prompts.yml="${release_dir}/deploy/k3s/config/prompts.yml" \ + --from-file=ops-config.yaml="${release_dir}/deploy/k3s/config/ops-config.yaml" \ + --dry-run=client -o yaml \ + | kubectl_cmd -n "${K3S_NAMESPACE}" apply -f - + } + + apply_jobs() { + rendered_jobs="${rendered_dir}/jobs.yaml" + mkdir -p "${rendered_dir}" + cp "${release_dir}/deploy/k3s/jobs.yaml" "${rendered_jobs}" + sed -i "s#image: geo-rankly/migrate:latest#image: $(image_ref migrate)#" "${rendered_jobs}" + + kubectl_cmd -n "${K3S_NAMESPACE}" delete job migrate minio-init --ignore-not-found + kubectl_cmd -n "${K3S_NAMESPACE}" apply -f "${rendered_jobs}" + } + + run_migrations() { + echo "Running k3s migration job..." + apply_jobs + kubectl_cmd -n "${K3S_NAMESPACE}" wait --for=condition=complete --timeout=900s job/migrate + kubectl_cmd -n "${K3S_NAMESPACE}" logs job/migrate --tail=120 + } + + rollout_deployments() { + if [ -z "${DEPLOYMENT_LIST}" ]; then + return 0 + fi + for svc in ${DEPLOYMENT_LIST}; do + kubectl_cmd -n "${K3S_NAMESPACE}" set image "deployment/${svc}" "${svc}=$(image_ref "${svc}")" + done + for svc in ${DEPLOYMENT_LIST}; do + kubectl_cmd -n "${K3S_NAMESPACE}" rollout status "deployment/${svc}" --timeout=600s + done + } + + rollout_all_app_deployments() { + for svc in tenant-api ops-api worker-generate kol-assist-worker scheduler frontend ops-web; do + if kubectl_cmd -n "${K3S_NAMESPACE}" get "deployment/${svc}" >/dev/null 2>&1; then + kubectl_cmd -n "${K3S_NAMESPACE}" rollout status "deployment/${svc}" --timeout=600s + fi + done + } + + restart_config_consumers() { + for svc in tenant-api worker-generate kol-assist-worker scheduler ops-api; do + if kubectl_cmd -n "${K3S_NAMESPACE}" get "deployment/${svc}" >/dev/null 2>&1; then + kubectl_cmd -n "${K3S_NAMESPACE}" rollout restart "deployment/${svc}" + kubectl_cmd -n "${K3S_NAMESPACE}" rollout status "deployment/${svc}" --timeout=600s + fi + done + } + + apply_manifest() { + input="${MANIFEST_PATH}" + input="${input#./}" + input="${input#deploy/k3s/}" + input="${input%/}" + + case "${input}" in + ""|"."|"kustomization.yaml"|"kustomization.yml") + apply_rendered_kustomize + rollout_all_app_deployments + return 0 + ;; + /*|*..*|*" "*) + echo "Invalid manifest path: ${MANIFEST_PATH}" + exit 1 + ;; + esac + + full_path="${release_dir}/deploy/k3s/${input}" + if [ ! -f "${full_path}" ]; then + echo "Manifest file not found: ${full_path}" + exit 1 + fi + + case "${input}" in + config/*) + echo "Config source changed; updating generated ConfigMap and restarting config consumers." + apply_config_map + restart_config_consumers + ;; + apps.yaml|apps.yml) + echo "Apps manifest changed; applying rendered kustomize with Gitea Registry images." + apply_rendered_kustomize + rollout_all_app_deployments + ;; + secrets.yaml|secrets.yml) + kubectl_cmd -n "${K3S_NAMESPACE}" apply -f "${full_path}" + restart_config_consumers + ;; + jobs.yaml|jobs.yml) + apply_jobs + ;; + namespace.yaml|namespace.yml) + ensure_namespace + ;; + *.yaml|*.yml) + kubectl_cmd -n "${K3S_NAMESPACE}" apply -f "${full_path}" + ;; + *) + echo "manifest_path must point to a .yaml/.yml file under deploy/k3s" + exit 1 + ;; + esac + } + + ensure_namespace + if [ -n "${IMAGE_LIST}" ]; then + check_plain_http_registry + ensure_registry_secret + else + echo "No application images selected; skipping registry pull-secret setup." + fi + + if [ "${APPLY_KUSTOMIZE}" = "true" ]; then + if [ "${RUN_MIGRATIONS}" = "true" ]; then + echo "Applying k3s infrastructure before migrations..." + apply_rendered_infra_kustomize + else + echo "Applying full kustomize deployment with Gitea Registry images..." + apply_rendered_kustomize + fi + fi + + if [ "${SELECTED_TARGET}" = "manifest" ]; then + echo "Applying selected manifest: ${MANIFEST_PATH}" + apply_manifest + fi + + if [ "${RUN_MIGRATIONS}" = "true" ]; then + run_migrations + fi + + if [ "${APPLY_KUSTOMIZE}" = "true" ] && [ "${RUN_MIGRATIONS}" = "true" ]; then + echo "Applying app deployments after migrations..." + apply_rendered_kustomize + fi + + rollout_deployments + + ln -sfn "${release_dir}" "${DEPLOY_BASE}/current" + + kubectl_cmd -n "${K3S_NAMESPACE}" get pods -o wide + + 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 k3s 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}' IMAGE_LIST='${IMAGE_LIST}' DEPLOYMENT_LIST='${DEPLOYMENT_LIST}' APPLY_KUSTOMIZE='${APPLY_KUSTOMIZE}' RUN_MIGRATIONS='${RUN_MIGRATIONS}' SELECTED_TARGET='${SELECTED_TARGET}' MANIFEST_PATH='${MANIFEST_PATH}' K3S_NAMESPACE='${K3S_NAMESPACE}' KEEP_RELEASES='${KEEP_RELEASES}' REGISTRY_HOST='${REGISTRY_HOST}' REGISTRY_OWNER='${REGISTRY_OWNER}' REGISTRY_IMAGE_PREFIX='${REGISTRY_IMAGE_PREFIX}' REGISTRY_USERNAME='${REGISTRY_USERNAME}' REGISTRY_PLAIN_HTTP='${REGISTRY_PLAIN_HTTP}' sh -se" + + - name: Deployment summary + run: | + summary_file="${GITHUB_STEP_SUMMARY:-/tmp/nas-k3s-deploy-summary.md}" + { + echo "### NAS k3s deployment" + echo "" + echo "- Host: ${NAS_HOST}:${NAS_PORT}" + echo "- Deploy base: ${DEPLOY_BASE}" + echo "- Namespace: ${K3S_NAMESPACE}" + echo "- Release: ${RELEASE_ID}" + echo "- Image tag: ${IMAGE_TAG}" + echo "- Selected target: ${SELECTED_TARGET}" + echo "- Manifest path: ${MANIFEST_PATH:-}" + echo "- Gitea Registry: ${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}/:${IMAGE_TAG}" + echo "- Plain HTTP registry: ${REGISTRY_PLAIN_HTTP}" + echo "- Images: ${IMAGE_LIST:-none}" + echo "- Deployments: ${DEPLOYMENT_LIST:-none}" + echo "- Run migrations: ${RUN_MIGRATIONS}" + echo "" + echo "Useful NAS commands:" + echo "" + echo '```sh' + echo "kubectl -n ${K3S_NAMESPACE} get pods" + echo "kubectl -n ${K3S_NAMESPACE} rollout status deploy/frontend" + echo "kubectl -n ${K3S_NAMESPACE} logs job/migrate" + echo '```' + } >> "${summary_file}" diff --git a/.gitea/workflows/offline-package.yml b/.gitea/workflows/offline-package.yml index 9d7bb50..e01aa44 100644 --- a/.gitea/workflows/offline-package.yml +++ b/.gitea/workflows/offline-package.yml @@ -4,13 +4,33 @@ on: workflow_dispatch: inputs: image_tag: - description: "Docker image tag. Defaults to the current tag name or short commit SHA." + description: "Docker image tag. Defaults to the pushed commit SHA first 8 chars." required: false default: "" platform: description: "Target platform for the offline package." required: false default: "linux/amd64" + registry_host: + description: "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 become /." + required: true + default: "geo-rankly" + registry_username: + description: "Gitea Registry username" + required: true + default: "root" + registry_plain_http: + description: "Registry uses plain HTTP and must be configured as an insecure registry." + required: true + default: "true" push: tags: - "v*" @@ -27,6 +47,13 @@ jobs: DOCKER_BUILDKIT: "1" REQUESTED_IMAGE_TAG: ${{ inputs.image_tag }} REQUESTED_PLATFORM: ${{ inputs.platform }} + REGISTRY_HOST: ${{ inputs.registry_host }} + REGISTRY_OWNER: ${{ inputs.registry_owner }} + REGISTRY_IMAGE_PREFIX: ${{ inputs.registry_image_prefix }} + REGISTRY_USERNAME: ${{ inputs.registry_username }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + REGISTRY_PLAIN_HTTP: ${{ inputs.registry_plain_http }} + IMAGE_LIST: migrate tenant-api ops-api worker-generate kol-assist-worker scheduler frontend ops-web steps: - name: Checkout uses: actions/checkout@v4 @@ -58,28 +85,56 @@ jobs: run: | image_tag="${REQUESTED_IMAGE_TAG}" if [ -z "${image_tag}" ]; then - case "${GITHUB_REF:-}" in - refs/tags/*) - image_tag="${GITHUB_REF#refs/tags/}" - ;; - *) - image_tag="$(git rev-parse --short=12 HEAD)" - ;; - esac + image_tag="$(git rev-parse --short=8 HEAD)" fi platform="${REQUESTED_PLATFORM:-linux/amd64}" + registry_host="${REGISTRY_HOST:-192.168.100.19:13000}" + registry_owner="${REGISTRY_OWNER:-root}" + registry_image_prefix="${REGISTRY_IMAGE_PREFIX:-geo-rankly}" + registry_username="${REGISTRY_USERNAME:-root}" + registry_plain_http="${REGISTRY_PLAIN_HTTP:-true}" + + registry_host="${registry_host#http://}" + registry_host="${registry_host#https://}" + registry_host="${registry_host%/}" + + { + 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}" + } >> "${GITHUB_ENV}" + printf '%s\n' "${image_tag}" > .ci-image-tag printf '%s\n' "${platform}" > .ci-platform echo "Image tag: ${image_tag}" echo "Platform: ${platform}" + echo "Gitea Registry: ${registry_host}/${registry_owner}/${registry_image_prefix}/:${image_tag}" + + - name: Verify current images in Gitea Registry + run: | + set -eu + image_tag="$(cat .ci-image-tag)" + IMAGE_TAG="${image_tag}" bash deploy/scripts/gitea-registry-images.sh verify || { + echo "::error::Missing image(s) for ${image_tag}. CI must publish images before packaging." + exit 1 + } + + - name: Login to Gitea Registry + run: | + set -eu + printf '%s\n' "${REGISTRY_PASSWORD}" \ + | docker login "${REGISTRY_HOST}" -u "${REGISTRY_USERNAME}" --password-stdin - name: Build offline deployment package run: | image_tag="$(cat .ci-image-tag)" platform="$(cat .ci-platform)" mkdir -p artifacts - PACKAGE_OUTPUT_DIR="${PWD}/artifacts" bash deploy/scripts/package.sh "${image_tag}" "${platform}" + source_registry="${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}" + SOURCE_IMAGE_REGISTRY="${source_registry}" PACKAGE_OUTPUT_DIR="${PWD}/artifacts" bash deploy/scripts/package.sh "${image_tag}" "${platform}" ls -lh artifacts - name: Upload offline package diff --git a/deploy/scripts/gitea-registry-images.sh b/deploy/scripts/gitea-registry-images.sh new file mode 100644 index 0000000..d6834aa --- /dev/null +++ b/deploy/scripts/gitea-registry-images.sh @@ -0,0 +1,358 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +MODE="${1:-ensure}" + +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}" +REGISTRY_SCHEME="${REGISTRY_SCHEME:-}" +IMAGE_TAG="${IMAGE_TAG:-}" +IMAGE_LIST="${IMAGE_LIST:-tenant-api ops-api worker-generate kol-assist-worker scheduler frontend ops-web migrate}" +DELETE_OLD_IMAGE_TAGS="${DELETE_OLD_IMAGE_TAGS:-true}" +PUSH_LATEST="${PUSH_LATEST:-true}" +FORCE_BUILD="${FORCE_BUILD:-false}" +TARGET_PLATFORM="${TARGET_PLATFORM:-}" + +case "${MODE}" in + ensure|verify) ;; + *) + echo "Usage: $0 [ensure|verify]" >&2 + exit 1 + ;; +esac + +if [[ -z "${IMAGE_TAG}" ]]; then + IMAGE_TAG="$(git -C "${REPO_ROOT}" rev-parse --short=8 HEAD)" +fi + +if [[ -z "${REGISTRY_SCHEME}" ]]; then + if [[ "${REGISTRY_PLAIN_HTTP}" == "true" ]]; then + REGISTRY_SCHEME="http" + else + REGISTRY_SCHEME="https" + fi +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 + +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." >&2 + exit 1 + fi +done + +if [[ -z "${REGISTRY_PASSWORD}" ]]; then + echo "REGISTRY_PASSWORD is required." >&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 ! command -v curl >/dev/null 2>&1; then + echo "curl is required." >&2 + exit 1 +fi + +if [[ "${MODE}" == "ensure" ]]; then + if ! command -v docker >/dev/null 2>&1; then + echo "docker is required." >&2 + exit 1 + fi + + 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 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 +fi + +image_name() { + local svc="$1" + printf '%s/%s/%s/%s' \ + "${REGISTRY_HOST}" \ + "${REGISTRY_OWNER}" \ + "${REGISTRY_IMAGE_PREFIX}" \ + "${svc}" +} + +registry_repo_path() { + local svc="$1" + printf '%s/%s/%s' "${REGISTRY_OWNER}" "${REGISTRY_IMAGE_PREFIX}" "${svc}" +} + +registry_api_base() { + local svc="$1" + printf '%s://%s/v2/%s' \ + "${REGISTRY_SCHEME}" \ + "${REGISTRY_HOST}" \ + "$(registry_repo_path "${svc}")" +} + +curl_registry() { + curl -fsS -u "${REGISTRY_USERNAME}:${REGISTRY_PASSWORD}" "$@" +} + +manifest_digest() { + local svc="$1" tag="$2" base headers status digest + base="$(registry_api_base "${svc}")" + headers="$(mktemp)" + status="$( + curl -sS \ + -u "${REGISTRY_USERNAME}:${REGISTRY_PASSWORD}" \ + -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \ + -H 'Accept: application/vnd.oci.image.manifest.v1+json' \ + -D "${headers}" \ + -o /dev/null \ + -w '%{http_code}' \ + "${base}/manifests/${tag}" || true + )" + + case "${status}" in + 200) + digest="$(awk 'BEGIN{IGNORECASE=1} /^Docker-Content-Digest:/ {gsub("\r", "", $2); print $2; exit}' "${headers}")" + rm -f "${headers}" + printf '%s' "${digest}" + ;; + 404) + rm -f "${headers}" + return 1 + ;; + 401|403) + rm -f "${headers}" + echo "Registry authentication failed while checking ${svc}:${tag}." >&2 + exit 1 + ;; + *) + rm -f "${headers}" + echo "Failed to check ${svc}:${tag}; registry returned HTTP ${status}." >&2 + exit 1 + ;; + esac +} + +tag_exists() { + local svc="$1" tag="$2" + manifest_digest "${svc}" "${tag}" >/dev/null +} + +list_tags() { + local svc="$1" base json + base="$(registry_api_base "${svc}")" + json="$(curl_registry "${base}/tags/list" 2>/dev/null || true)" + if [[ -z "${json}" ]]; then + return 0 + fi + printf '%s' "${json}" \ + | tr -d '\n' \ + | sed -n 's/.*"tags":[[]\([^]]*\)[]].*/\1/p' \ + | tr ',' '\n' \ + | tr -d '" ' \ + | sed '/^$/d' +} + +delete_manifest_digest() { + local svc="$1" digest="$2" base status + base="$(registry_api_base "${svc}")" + status="$( + curl -sS \ + -u "${REGISTRY_USERNAME}:${REGISTRY_PASSWORD}" \ + -X DELETE \ + -o /dev/null \ + -w '%{http_code}' \ + "${base}/manifests/${digest}" || true + )" + + case "${status}" in + 200|202|404) + return 0 + ;; + 405) + echo "Registry does not allow deleting manifests for ${svc}; skipping cleanup." >&2 + return 0 + ;; + *) + echo "Failed to delete old manifest ${svc}@${digest}; HTTP ${status}. Continuing." >&2 + return 0 + ;; + esac +} + +cleanup_old_hash_tags() { + local svc="$1" tag digest current_digest latest_digest + + if [[ "${DELETE_OLD_IMAGE_TAGS}" != "true" ]]; then + return 0 + fi + + current_digest="$(manifest_digest "${svc}" "${IMAGE_TAG}" 2>/dev/null || true)" + latest_digest="$(manifest_digest "${svc}" "latest" 2>/dev/null || true)" + + while IFS= read -r tag; do + [[ -n "${tag}" ]] || continue + [[ "${tag}" != "${IMAGE_TAG}" ]] || continue + [[ "${tag}" != "latest" ]] || continue + if [[ ! "${tag}" =~ ^[0-9a-f]{8}$ ]]; then + continue + fi + + digest="$(manifest_digest "${svc}" "${tag}" 2>/dev/null || true)" + if [[ -z "${digest}" ]]; then + continue + fi + if [[ -n "${current_digest}" && "${digest}" == "${current_digest}" ]]; then + continue + fi + if [[ -n "${latest_digest}" && "${digest}" == "${latest_digest}" ]]; then + continue + fi + + echo "Deleting old image tag ${svc}:${tag}" + delete_manifest_digest "${svc}" "${digest}" + done < <(list_tags "${svc}") +} + +platform_args=() +if [[ -n "${TARGET_PLATFORM}" ]]; then + platform_args=(--platform "${TARGET_PLATFORM}") +fi + +build_image() { + local svc="$1" repo + repo="$(image_name "${svc}")" + + case "${svc}" in + migrate) + docker build "${platform_args[@]}" \ + --target migrate \ + --tag "${repo}:${IMAGE_TAG}" \ + --tag "${repo}:latest" \ + -f "${REPO_ROOT}/server/Dockerfile" \ + "${REPO_ROOT}/server" + ;; + tenant-api|worker-generate|kol-assist-worker|scheduler|ops-api) + docker build "${platform_args[@]}" \ + --target runtime \ + --build-arg "SERVICE=${svc}" \ + --tag "${repo}:${IMAGE_TAG}" \ + --tag "${repo}:latest" \ + -f "${REPO_ROOT}/server/Dockerfile" \ + "${REPO_ROOT}/server" + ;; + frontend) + docker build "${platform_args[@]}" \ + --tag "${repo}:${IMAGE_TAG}" \ + --tag "${repo}:latest" \ + -f "${REPO_ROOT}/Dockerfile.frontend" \ + "${REPO_ROOT}" + ;; + ops-web) + docker build "${platform_args[@]}" \ + --tag "${repo}:${IMAGE_TAG}" \ + --tag "${repo}:latest" \ + -f "${REPO_ROOT}/Dockerfile.ops-web" \ + "${REPO_ROOT}" + ;; + *) + echo "Unsupported image service: ${svc}" >&2 + exit 1 + ;; + esac +} + +push_latest_from_existing_tag() { + local svc="$1" repo tag_digest latest_digest + + if [[ "${PUSH_LATEST}" != "true" ]]; then + return 0 + fi + + tag_digest="$(manifest_digest "${svc}" "${IMAGE_TAG}" 2>/dev/null || true)" + latest_digest="$(manifest_digest "${svc}" "latest" 2>/dev/null || true)" + if [[ -n "${tag_digest}" && "${tag_digest}" == "${latest_digest}" ]]; then + return 0 + fi + + repo="$(image_name "${svc}")" + echo "Updating ${svc}:latest to existing ${svc}:${IMAGE_TAG}" + docker pull "${repo}:${IMAGE_TAG}" + docker tag "${repo}:${IMAGE_TAG}" "${repo}:latest" + docker push "${repo}:latest" +} + +ensure_image() { + local svc="$1" repo + repo="$(image_name "${svc}")" + + if [[ "${FORCE_BUILD}" != "true" ]] && tag_exists "${svc}" "${IMAGE_TAG}"; then + echo "Image already current: ${repo}:${IMAGE_TAG}; skipping build." + push_latest_from_existing_tag "${svc}" + cleanup_old_hash_tags "${svc}" + return 0 + fi + + echo "Building image: ${repo}:${IMAGE_TAG}" + build_image "${svc}" + docker push "${repo}:${IMAGE_TAG}" + if [[ "${PUSH_LATEST}" == "true" ]]; then + docker push "${repo}:latest" + fi + cleanup_old_hash_tags "${svc}" +} + +verify_image() { + local svc="$1" repo + repo="$(image_name "${svc}")" + + if tag_exists "${svc}" "${IMAGE_TAG}"; then + echo "Image is current: ${repo}:${IMAGE_TAG}" + else + echo "Missing current image: ${repo}:${IMAGE_TAG}" >&2 + return 1 + fi +} + +echo "Gitea Registry: ${REGISTRY_SCHEME}://${REGISTRY_HOST}/${REGISTRY_OWNER}/${REGISTRY_IMAGE_PREFIX}/" +echo "Image tag: ${IMAGE_TAG}" +echo "Image list: ${IMAGE_LIST}" + +missing=0 +for svc in ${IMAGE_LIST}; do + if [[ "${MODE}" == "verify" ]]; then + verify_image "${svc}" || missing=1 + else + ensure_image "${svc}" + fi +done + +if [[ "${missing}" -ne 0 ]]; then + echo "One or more current images are missing. Run the matching CI workflow for this commit first." >&2 + exit 1 +fi diff --git a/deploy/scripts/package.sh b/deploy/scripts/package.sh index 99cc116..6ade8cc 100755 --- a/deploy/scripts/package.sh +++ b/deploy/scripts/package.sh @@ -20,6 +20,7 @@ DEPLOY_DIR="${REPO_ROOT}/deploy" IMAGE_TAG="${1:-latest}" PLATFORM="${2:-linux/amd64}" LOCAL_PREFIX="${LOCAL_PREFIX:-geo-rankly}" +SOURCE_IMAGE_REGISTRY="${SOURCE_IMAGE_REGISTRY:-}" OUTPUT_DIR="${PACKAGE_OUTPUT_DIR:-${REPO_ROOT}}" DATE_TAG="$(date +%Y%m%d)" # 将 linux/amd64 → amd64,用于包名 @@ -52,47 +53,56 @@ fi # 确保 builder 已启动(首次使用时需要) docker buildx inspect --bootstrap "${BUILDER_NAME}" > /dev/null -# ─── 构建自定义镜像 ──────────────────────────────────────────────────────────── -echo "==> [1/5] 构建自定义镜像 (platform: ${PLATFORM}, tag: ${IMAGE_TAG})" +# ─── 准备自定义镜像 ──────────────────────────────────────────────────────────── +echo "==> [1/5] 准备自定义镜像 (platform: ${PLATFORM}, tag: ${IMAGE_TAG})" cd "${REPO_ROOT}" -# 注意:--load 仅支持单平台;交叉编译时 buildx 使用 QEMU 或 Go 交叉编译 -_build() { - local target="$1" tag_suffix="$2" - shift 2 - echo " building ${LOCAL_PREFIX}/${tag_suffix}:${IMAGE_TAG}" +if [[ -n "${SOURCE_IMAGE_REGISTRY}" ]]; then + echo " pulling from ${SOURCE_IMAGE_REGISTRY}" + for svc in tenant-api worker-generate kol-assist-worker ops-api scheduler migrate frontend ops-web; do + echo " pulling ${SOURCE_IMAGE_REGISTRY}/${svc}:${IMAGE_TAG}" + docker pull --platform "${PLATFORM}" "${SOURCE_IMAGE_REGISTRY}/${svc}:${IMAGE_TAG}" + docker tag "${SOURCE_IMAGE_REGISTRY}/${svc}:${IMAGE_TAG}" "${LOCAL_PREFIX}/${svc}:${IMAGE_TAG}" + done +else + # 注意:--load 仅支持单平台;交叉编译时 buildx 使用 QEMU 或 Go 交叉编译 + _build() { + local target="$1" tag_suffix="$2" + shift 2 + echo " building ${LOCAL_PREFIX}/${tag_suffix}:${IMAGE_TAG}" + docker buildx build \ + --platform "${PLATFORM}" \ + --target "${target}" \ + "$@" \ + --tag "${LOCAL_PREFIX}/${tag_suffix}:${IMAGE_TAG}" \ + --load \ + -f server/Dockerfile \ + server/ + } + + _build runtime tenant-api --build-arg SERVICE=tenant-api + _build runtime worker-generate --build-arg SERVICE=worker-generate + _build runtime kol-assist-worker --build-arg SERVICE=kol-assist-worker + _build runtime ops-api --build-arg SERVICE=ops-api + _build runtime scheduler --build-arg SERVICE=scheduler + _build migrate migrate # migrate stage 不需要 SERVICE arg + + echo " building ${LOCAL_PREFIX}/frontend:${IMAGE_TAG}" docker buildx build \ --platform "${PLATFORM}" \ - --target "${target}" \ - "$@" \ - --tag "${LOCAL_PREFIX}/${tag_suffix}:${IMAGE_TAG}" \ + --tag "${LOCAL_PREFIX}/frontend:${IMAGE_TAG}" \ --load \ - -f server/Dockerfile \ - server/ -} + -f Dockerfile.frontend \ + . -_build runtime tenant-api --build-arg SERVICE=tenant-api -_build runtime worker-generate --build-arg SERVICE=worker-generate -_build runtime kol-assist-worker --build-arg SERVICE=kol-assist-worker -_build runtime ops-api --build-arg SERVICE=ops-api -_build runtime scheduler --build-arg SERVICE=scheduler -_build migrate migrate # migrate stage 不需要 SERVICE arg - -echo " building ${LOCAL_PREFIX}/frontend:${IMAGE_TAG}" -docker buildx build \ - --platform "${PLATFORM}" \ - --tag "${LOCAL_PREFIX}/frontend:${IMAGE_TAG}" \ - --load \ - -f Dockerfile.frontend \ - . - -echo " building ${LOCAL_PREFIX}/ops-web:${IMAGE_TAG}" -docker buildx build \ - --platform "${PLATFORM}" \ - --tag "${LOCAL_PREFIX}/ops-web:${IMAGE_TAG}" \ - --load \ - -f Dockerfile.ops-web \ - . + echo " building ${LOCAL_PREFIX}/ops-web:${IMAGE_TAG}" + docker buildx build \ + --platform "${PLATFORM}" \ + --tag "${LOCAL_PREFIX}/ops-web:${IMAGE_TAG}" \ + --load \ + -f Dockerfile.ops-web \ + . +fi # ─── 拉取基础设施镜像(指定平台) ───────────────────────────────────────────── echo "==> [2/5] 拉取基础设施镜像 (platform: ${PLATFORM})" diff --git a/task_plan.md b/task_plan.md index a66c962..7149305 100644 --- a/task_plan.md +++ b/task_plan.md @@ -269,6 +269,13 @@ Phase 37 - [x] Update findings/progress and summarize deployment commands plus required edits - **Status:** complete +### Phase 39: Gitea Registry CI/CD Boundary +- [x] Keep image publishing owned by frontend/backend CI only +- [x] Make CD/offline workflows verify commit8 images instead of building fallback images +- [x] Keep deploy-config CI scoped to deployment config validation only +- [x] Run workflow YAML and shell syntax checks +- **Status:** complete + ## Key Questions 1. How should the desktop client defer and locally optimize monitor-task execution without violating one-task-per-platform serialism? 2. What is the smallest durable local cache that allows same-day resume while safely dropping stale next-day monitor tasks? @@ -320,6 +327,8 @@ Phase 37 | Split daily monitoring plan loading into selection and aggregation steps | Primary-client election and platform authorization aggregation have different explainability and testability concerns; two smaller queries are easier to inspect and later move to sqlc | | Treat platform-account health as execution state, not authorization state | `health != live` should not suppress daily task planning, dashboard authorization, or detail history; only desktop-client online state gates immediate collection | | Start k3s with a self-contained stack that mirrors the existing compose deployment | The repo already has working Docker images and compose topology; k3s should first preserve service names and runtime assumptions before introducing managed cloud replacements | +| Publish deployable images only from frontend/backend CI | Deployment config has no runtime image ownership; `deploy-config-ci` stays a validation workflow while frontend/backend CI publish Gitea Registry images tagged by commit8 | +| Use commit hash first 8 chars as the canonical deploy image tag | CD/offline packaging can compare the pushed commit to Registry tags and fail fast when CI has not produced the matching image | ## Errors Encountered | Error | Attempt | Resolution |