Files
geo/deploy/scripts/gitea-registry-images.sh
T
root 316e2a34aa
Deployment Config CI / Deployment Config (push) Successful in 14s
Frontend CI / Frontend (push) Successful in 1m57s
Backend CI / Backend (push) Successful in 41m0s
Revert "feat(workflows): update registry host to 192.168.100.49 across multiple CI/CD files"
This reverts commit 855e061244.
2026-06-02 23:25:19 +08:00

568 lines
14 KiB
Bash

#!/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 browser-fetch ops-api worker-generate kol-assist-worker scheduler frontend ops-web migrate}"
DELETE_OLD_IMAGE_TAGS="${DELETE_OLD_IMAGE_TAGS:-true}"
KEEP_IMAGE_TAGS="${KEEP_IMAGE_TAGS:-2}"
GITEA_PACKAGE_PAGE_LIMIT="${GITEA_PACKAGE_PAGE_LIMIT:-100}"
PUSH_LATEST="${PUSH_LATEST:-true}"
FORCE_BUILD="${FORCE_BUILD:-false}"
TARGET_PLATFORM="${TARGET_PLATFORM:-}"
case "${MODE}" in
ensure|verify|cleanup) ;;
*)
echo "Usage: $0 [ensure|verify|cleanup]" >&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
case "${KEEP_IMAGE_TAGS}" in
''|*[!0-9]*)
echo "KEEP_IMAGE_TAGS must be a non-negative integer." >&2
exit 1
;;
esac
case "${GITEA_PACKAGE_PAGE_LIMIT}" in
''|*[!0-9]*)
echo "GITEA_PACKAGE_PAGE_LIMIT must be a positive integer." >&2
exit 1
;;
0)
echo "GITEA_PACKAGE_PAGE_LIMIT must be greater than 0." >&2
exit 1
;;
esac
if ! command -v curl >/dev/null 2>&1; then
echo "curl is required." >&2
exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "python3 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}")"
}
gitea_api_base() {
printf '%s://%s/api/v1' "${REGISTRY_SCHEME}" "${REGISTRY_HOST}"
}
gitea_package_base_url() {
local svc="$1" encoded_owner encoded_name
encoded_owner="$(url_encode_segment "${REGISTRY_OWNER}")"
encoded_name="$(url_encode_segment "$(package_name "${svc}")")"
printf '%s/packages/%s/container/%s' \
"$(gitea_api_base)" \
"${encoded_owner}" \
"${encoded_name}"
}
gitea_package_list_url() {
local svc="$1" page="$2" encoded_owner encoded_query
encoded_owner="$(url_encode_segment "${REGISTRY_OWNER}")"
encoded_query="$(url_encode_segment "$(package_name "${svc}")")"
printf '%s/packages/%s/?type=container&q=%s&page=%s&limit=%s' \
"$(gitea_api_base)" \
"${encoded_owner}" \
"${encoded_query}" \
"${page}" \
"${GITEA_PACKAGE_PAGE_LIMIT}"
}
package_name() {
local svc="$1"
printf '%s/%s' "${REGISTRY_IMAGE_PREFIX}" "${svc}"
}
url_encode_segment() {
python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$1"
}
curl_registry() {
curl -fsS -u "${REGISTRY_USERNAME}:${REGISTRY_PASSWORD}" "$@"
}
curl_gitea_api_json() {
local url="$1" body_file status body
body_file="$(mktemp)"
status="$(
curl -sS \
-u "${REGISTRY_USERNAME}:${REGISTRY_PASSWORD}" \
-o "${body_file}" \
-w '%{http_code}' \
--path-as-is \
"${url}" || true
)"
body="$(<"${body_file}")"
rm -f "${body_file}"
case "${status}" in
200)
printf '%s' "${body}"
;;
401|403)
echo "Gitea package API authentication failed." >&2
exit 1
;;
404)
return 1
;;
*)
echo "Gitea package API request failed; HTTP ${status}. URL: ${url}" >&2
[[ -z "${body}" ]] || echo "${body}" >&2
return 1
;;
esac
}
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
}
delete_manifest_reference() {
local svc="$1" reference="$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/${reference}" || true
)"
case "${status}" in
200|202|404)
return 0
;;
405)
echo "Registry does not allow deleting ${svc}:${reference}; cleanup failed." >&2
return 1
;;
*)
echo "Failed to delete old image tag ${svc}:${reference}; HTTP ${status}." >&2
return 1
;;
esac
}
delete_gitea_package_version() {
local svc="$1" tag="$2" encoded_tag url status
encoded_tag="$(url_encode_segment "${tag}")"
url="$(gitea_package_base_url "${svc}")/${encoded_tag}"
status="$(
curl -sS \
-u "${REGISTRY_USERNAME}:${REGISTRY_PASSWORD}" \
-X DELETE \
-o /dev/null \
-w '%{http_code}' \
--path-as-is \
"${url}" || true
)"
case "${status}" in
200|202|204)
return 0
;;
404)
return 1
;;
401|403)
echo "Gitea package API authentication failed while deleting ${svc}:${tag}." >&2
exit 1
;;
*)
echo "Gitea package API could not delete ${svc}:${tag}; HTTP ${status}." >&2
return 1
;;
esac
}
delete_old_hash_tag() {
local svc="$1" tag="$2"
if delete_gitea_package_version "${svc}" "${tag}"; then
return 0
fi
echo "Falling back to registry tag delete for ${svc}:${tag}"
delete_manifest_reference "${svc}" "${tag}"
}
list_gitea_hash_versions() {
local svc="$1" page=1 json count expected_name
expected_name="$(package_name "${svc}")"
while true; do
json="$(
curl_gitea_api_json "$(gitea_package_list_url "${svc}" "${page}")"
)" || return 1
count="$(
printf '%s' "${json}" \
| python3 -c 'import json, sys; data = json.load(sys.stdin); print(len(data) if isinstance(data, list) else 0)'
)"
printf '%s' "${json}" \
| python3 -c '
import json
import re
import sys
expected_name = sys.argv[1].lower()
data = json.load(sys.stdin)
if not isinstance(data, list):
raise SystemExit("Gitea package API response was not a list")
for item in data:
if not isinstance(item, dict):
continue
if item.get("type") not in ("", "container"):
continue
if (item.get("name") or "").lower() != expected_name:
continue
version = item.get("version") or ""
if not re.fullmatch(r"[0-9a-f]{8}", version):
continue
created_at = item.get("created_at") or ""
print(f"{created_at}\t{version}")
' "${expected_name}"
if (( count < GITEA_PACKAGE_PAGE_LIMIT )); then
break
fi
page=$((page + 1))
done | sort -r -k1,1 -k2,2
}
cleanup_old_hash_tags() {
local svc="$1" kept=0 hash_tags_to_keep line tag versions_file keep_file
if [[ "${DELETE_OLD_IMAGE_TAGS}" != "true" ]]; then
return 0
fi
hash_tags_to_keep="${KEEP_IMAGE_TAGS}"
if [[ "${PUSH_LATEST}" == "true" && "${hash_tags_to_keep}" -gt 0 ]]; then
hash_tags_to_keep=$((hash_tags_to_keep - 1))
fi
versions_file="$(mktemp)"
keep_file="$(mktemp)"
if ! list_gitea_hash_versions "${svc}" >"${versions_file}"; then
rm -f "${versions_file}" "${keep_file}"
echo "Failed to list Gitea package versions for ${svc}; cleanup aborted." >&2
return 1
fi
if (( hash_tags_to_keep > 0 )); then
while IFS= read -r line; do
tag="${line#*$'\t'}"
if [[ "${tag}" == "${IMAGE_TAG}" ]]; then
printf '%s\n' "${tag}" >"${keep_file}"
break
fi
done <"${versions_file}"
while IFS= read -r line; do
tag="${line#*$'\t'}"
[[ -n "${tag}" ]] || continue
if grep -Fxq "${tag}" "${keep_file}"; then
continue
fi
kept="$(wc -l <"${keep_file}" | tr -d ' ')"
if (( kept >= hash_tags_to_keep )); then
break
fi
printf '%s\n' "${tag}" >>"${keep_file}"
done <"${versions_file}"
fi
kept=0
while IFS= read -r line; do
tag="${line#*$'\t'}"
[[ -n "${tag}" ]] || continue
if grep -Fxq "${tag}" "${keep_file}"; then
kept=$((kept + 1))
echo "Keeping image tag ${svc}:${tag} (${kept}/${hash_tags_to_keep} hash tags, total target ${KEEP_IMAGE_TAGS} including latest)"
continue
fi
echo "Deleting old image tag ${svc}:${tag}"
if ! delete_old_hash_tag "${svc}" "${tag}"; then
rm -f "${versions_file}" "${keep_file}"
return 1
fi
done <"${versions_file}"
rm -f "${versions_file}" "${keep_file}"
}
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"
;;
browser-fetch)
docker build "${platform_args[@]}" \
--target browser-fetch \
--build-arg "SERVICE=browser-fetch" \
--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}"
echo "Keeping image versions per service: ${KEEP_IMAGE_TAGS} (latest counts as one when PUSH_LATEST=true)"
missing=0
for svc in ${IMAGE_LIST}; do
if [[ "${MODE}" == "verify" ]]; then
verify_image "${svc}" || missing=1
elif [[ "${MODE}" == "cleanup" ]]; then
cleanup_old_hash_tags "${svc}"
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