ci(deploy): publish images via Gitea Registry and verify on CD
CI workflows now own image publishing: backend-ci and frontend-ci push service images to the Gitea Registry tagged with commit8. CD workflows (nas-compose-deploy, nas-compose-service-deploy, offline-package, the new nas-k3s-deploy) pull and verify those tags instead of rebuilding, so deployments fail fast when CI hasn't produced the matching image. The shared deploy/scripts/gitea-registry-images.sh helper drives both the ensure (CI) and verify (CD) paths, and package.sh can now hydrate its local images from the registry via SOURCE_IMAGE_REGISTRY. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <prefix>/<service>."
|
||||
required: true
|
||||
default: "geo-rankly"
|
||||
registry_username:
|
||||
description: "Gitea Registry username"
|
||||
required: true
|
||||
default: "root"
|
||||
registry_password_source:
|
||||
description: "Gitea Registry password source"
|
||||
required: true
|
||||
default: "repository_secret"
|
||||
type: choice
|
||||
options:
|
||||
- repository_secret
|
||||
- workflow_input
|
||||
registry_password:
|
||||
description: "Gitea Registry password/token. Used only when registry_password_source 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}/<service>:${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}/<service>:${IMAGE_TAG}"
|
||||
echo ""
|
||||
echo "Useful NAS commands:"
|
||||
echo ""
|
||||
|
||||
@@ -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 <prefix>/<service>."
|
||||
required: true
|
||||
default: "geo-rankly"
|
||||
registry_username:
|
||||
description: "Gitea Registry username"
|
||||
required: true
|
||||
default: "root"
|
||||
registry_password_source:
|
||||
description: "Gitea Registry password source"
|
||||
required: true
|
||||
default: "repository_secret"
|
||||
type: choice
|
||||
options:
|
||||
- repository_secret
|
||||
- workflow_input
|
||||
registry_password:
|
||||
description: "Gitea Registry password/token. Used only when registry_password_source 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}/<service>:${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}/<service>:${IMAGE_TAG}"
|
||||
echo "- Selected input: ${SELECTED_SERVICE}"
|
||||
echo "- Compose service(s): ${SERVICE_LIST}"
|
||||
echo "- Run migrations: ${RUN_MIGRATIONS}"
|
||||
|
||||
@@ -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 <prefix>/<service>."
|
||||
required: true
|
||||
default: "geo-rankly"
|
||||
registry_username:
|
||||
description: "Gitea Registry username"
|
||||
required: true
|
||||
default: "root"
|
||||
registry_password_source:
|
||||
description: "Gitea Registry password source"
|
||||
required: true
|
||||
default: "repository_secret"
|
||||
type: choice
|
||||
options:
|
||||
- repository_secret
|
||||
- workflow_input
|
||||
registry_password:
|
||||
description: "Gitea Registry password/token. Used only when registry_password_source 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}/<service>:${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}/<service>:${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}"
|
||||
@@ -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 <prefix>/<service>."
|
||||
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}/<service>:${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
|
||||
|
||||
@@ -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}/<service>"
|
||||
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
|
||||
+45
-35
@@ -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})"
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user