refactor(release): 改为 Agent 双阶段人工发布
删除 Gitea Actions、Tag/Main 自动流水线和旧 Runner 配置,取消 Git 操作与发布授权的绑定。\n\n新增本地镜像发布、固定生产部署助手、digest manifest、迁移安全检查、simulation 冒烟及显式回滚流程。\n\n验证:pnpm lint、pnpm test、pnpm build、Go 全量测试、ShellCheck、Compose 配置、人工发布测试和 linux/amd64 完整栈冒烟。
This commit is contained in:
@@ -1,36 +0,0 @@
|
||||
log:
|
||||
level: info
|
||||
|
||||
runner:
|
||||
file: /data/.runner
|
||||
capacity: 1
|
||||
envs:
|
||||
GOPROXY: "https://goproxy.cn,direct"
|
||||
GOSUMDB: "sum.golang.google.cn"
|
||||
PATH: "/opt/easyai-gateway-ci/bin:/opt/easyai-gateway-ci/toolchains/go/bin:/opt/easyai-gateway-ci/toolchains/node/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
timeout: 3h
|
||||
shutdown_timeout: 5m
|
||||
insecure: false
|
||||
fetch_timeout: 5s
|
||||
fetch_interval: 2s
|
||||
labels:
|
||||
- "easyai-gateway-ci-unprivileged-v2:docker://docker.io/library/node:24.16.0-bookworm@sha256:40ad9f3064e67d6860b4bc3fe1880b2953934fd6320ada990e45fe0efa6badd7"
|
||||
|
||||
cache:
|
||||
enabled: false
|
||||
|
||||
container:
|
||||
docker_host: "-"
|
||||
privileged: false
|
||||
force_pull: false
|
||||
force_rebuild: false
|
||||
require_docker: true
|
||||
docker_timeout: 1m
|
||||
bind_workdir: false
|
||||
workdir_parent: workspace
|
||||
options: "--volume /opt/easyai-gateway-ci:/opt/easyai-gateway-ci:ro"
|
||||
valid_volumes:
|
||||
- "/opt/easyai-gateway-ci"
|
||||
|
||||
host:
|
||||
workdir_parent: /data/host-work
|
||||
@@ -1,44 +0,0 @@
|
||||
[Unit]
|
||||
Description=EasyAI AI Gateway rootless DinD Gitea Actions runner v2
|
||||
Requires=docker.service
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
Environment=RUNNER_SECURITY_OPTIONS=
|
||||
Environment=RUNNER_IMAGE_REF=
|
||||
EnvironmentFile=-/etc/easyai-gateway-ci-v2/runner.env
|
||||
ExecStartPre=-/usr/bin/docker rm --force easyai-gateway-ci-v2-runner
|
||||
ExecStart=/usr/bin/docker run --rm --pull=never --name easyai-gateway-ci-v2-runner --privileged --cpus 2 --memory 2g --memory-swap 3g --pids-limit 1024 $RUNNER_SECURITY_OPTIONS --volume easyai-gateway-ci-v2-data:/data --volume /etc/easyai-gateway-ci-v2/config.yaml:/config.yaml:ro --volume /etc/easyai-gateway-ci-v2/daemon.json:/home/rootless/.config/docker/daemon.json:ro --volume /opt/easyai-gateway-ci:/opt/easyai-gateway-ci:ro --env CONFIG_FILE=/config.yaml --env DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns --env DOCKERD_ROOTLESS_ROOTLESSKIT_MTU=65520 --env DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--pidns $RUNNER_IMAGE_REF
|
||||
ExecStop=-/usr/bin/docker stop --time 360 easyai-gateway-ci-v2-runner
|
||||
Restart=always
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=60s
|
||||
TimeoutStopSec=390s
|
||||
|
||||
# systemd only launches the host Docker client. Pull-request jobs run inside the
|
||||
# nested rootless daemon and never receive the host or nested Docker socket.
|
||||
NoNewPrivileges=true
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectControlGroups=true
|
||||
ProtectClock=true
|
||||
ProtectHostname=true
|
||||
LockPersonality=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
SystemCallArchitectures=native
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1 +0,0 @@
|
||||
fce76a30ba2d7f9aa514418a2e09be7bbe7aaf4e
|
||||
Executable
+233
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
[[ $(id -u) -eq 0 ]] || { echo 'run as root' >&2; exit 1; }
|
||||
|
||||
config_file=${AI_GATEWAY_RELEASE_CONFIG:-/etc/easyai-ai-gateway-release.conf}
|
||||
manifest_tool=${AI_GATEWAY_RELEASE_MANIFEST_TOOL:-/usr/local/lib/easyai-ai-gateway-release/release-manifest.mjs}
|
||||
[[ -f $config_file && ! -L $config_file ]] || { echo 'missing release config' >&2; exit 1; }
|
||||
[[ -f $manifest_tool && ! -L $manifest_tool ]] || { echo 'missing release manifest validator' >&2; exit 1; }
|
||||
# shellcheck source=/dev/null
|
||||
source "$config_file"
|
||||
|
||||
: "${DEPLOY_DIR:?}"
|
||||
: "${COMPOSE_FILE:?}"
|
||||
: "${RELEASES_DIR:?}"
|
||||
: "${BACKUP_DIR:?}"
|
||||
: "${PUBLIC_BASE_URL:?}"
|
||||
: "${API_PORT:?}"
|
||||
: "${WEB_PORT:?}"
|
||||
: "${POSTGRES_SERVICE:?}"
|
||||
: "${POSTGRES_USER:?}"
|
||||
: "${POSTGRES_DATABASE:?}"
|
||||
: "${BACKUP_RETENTION:?}"
|
||||
|
||||
[[ $DEPLOY_DIR == /* && $COMPOSE_FILE == /* && $RELEASES_DIR == /* && $BACKUP_DIR == /* ]] || {
|
||||
echo 'release paths must be absolute' >&2
|
||||
exit 1
|
||||
}
|
||||
[[ $BACKUP_RETENTION =~ ^[1-9][0-9]*$ ]] || { echo 'invalid BACKUP_RETENTION' >&2; exit 1; }
|
||||
[[ $PUBLIC_BASE_URL =~ ^https://[A-Za-z0-9.-]+$ ]] || { echo 'invalid PUBLIC_BASE_URL' >&2; exit 1; }
|
||||
[[ -d $DEPLOY_DIR && -f $COMPOSE_FILE ]] || { echo 'deployment directory is unavailable' >&2; exit 1; }
|
||||
|
||||
for command in docker curl node flock install; do
|
||||
command -v "$command" >/dev/null 2>&1 || { echo "$command is required" >&2; exit 1; }
|
||||
done
|
||||
docker info >/dev/null 2>&1 || { echo 'Docker Engine is unavailable' >&2; exit 1; }
|
||||
docker compose version >/dev/null 2>&1 || { echo 'Docker Compose v2 is required' >&2; exit 1; }
|
||||
|
||||
install -d -m 0700 "$RELEASES_DIR" "$BACKUP_DIR"
|
||||
exec 9>"$RELEASES_DIR/.release.lock"
|
||||
flock -n 9 || { echo 'another release operation is running' >&2; exit 1; }
|
||||
|
||||
compose=(docker compose --project-directory "$DEPLOY_DIR" -f "$COMPOSE_FILE")
|
||||
current_manifest=$RELEASES_DIR/current.json
|
||||
|
||||
manifest_get() {
|
||||
node "$manifest_tool" get "$1" "$2"
|
||||
}
|
||||
|
||||
validate_image() {
|
||||
local image=$1
|
||||
local source_sha=$2
|
||||
local require_revision=$3
|
||||
local revision architecture
|
||||
|
||||
[[ $image =~ @sha256:[0-9a-f]{64}$ ]] || { echo "image is not digest-pinned: $image" >&2; return 1; }
|
||||
docker pull --platform linux/amd64 "$image"
|
||||
architecture=$(docker image inspect "$image" --format '{{.Os}}/{{.Architecture}}')
|
||||
[[ $architecture == linux/amd64 ]] || { echo "unexpected image architecture: $architecture" >&2; return 1; }
|
||||
if [[ $require_revision == true ]]; then
|
||||
revision=$(docker image inspect "$image" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')
|
||||
[[ $revision == "$source_sha" ]] || { echo "image revision does not match release: $image" >&2; return 1; }
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_url() {
|
||||
local label=$1
|
||||
local url=$2
|
||||
local expected=${3:-}
|
||||
local body=
|
||||
|
||||
for _ in $(seq 1 60); do
|
||||
if body=$(curl -fsS --max-time 5 "$url" 2>/dev/null); then
|
||||
if [[ -z $expected || $body == *"$expected"* ]]; then
|
||||
echo "[release] verified $label"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "release probe failed: $label ($url)" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
verify_runtime() {
|
||||
wait_for_url 'internal API health' "http://127.0.0.1:$API_PORT/api/v1/healthz" 'easyai-ai-gateway'
|
||||
wait_for_url 'internal API readiness' "http://127.0.0.1:$API_PORT/api/v1/readyz" '"ok":true'
|
||||
wait_for_url 'internal OpenAPI' "http://127.0.0.1:$API_PORT/api/v1/openapi.json" '"/api/v1/chat/completions"'
|
||||
wait_for_url 'Web API reverse proxy' "http://127.0.0.1:$WEB_PORT/api/v1/healthz" 'easyai-ai-gateway'
|
||||
wait_for_url 'Web application' "http://127.0.0.1:$WEB_PORT/" 'EasyAI AI Gateway'
|
||||
wait_for_url 'public API health' "$PUBLIC_BASE_URL/api/v1/healthz" 'easyai-ai-gateway'
|
||||
wait_for_url 'public API readiness' "$PUBLIC_BASE_URL/api/v1/readyz" '"ok":true'
|
||||
wait_for_url 'public OpenAPI' "$PUBLIC_BASE_URL/api/v1/openapi.json" '"/api/v1/chat/completions"'
|
||||
}
|
||||
|
||||
activate_images() {
|
||||
local api_image=$1
|
||||
local web_image=$2
|
||||
export AI_GATEWAY_API_IMAGE=$api_image
|
||||
export AI_GATEWAY_WEB_IMAGE=$web_image
|
||||
"${compose[@]}" up -d --no-build --pull always api web
|
||||
}
|
||||
|
||||
restore_previous() {
|
||||
local previous=$1
|
||||
[[ -f $previous ]] || return 1
|
||||
local previous_api previous_web
|
||||
previous_api=$(manifest_get "$previous" images.api)
|
||||
previous_web=$(manifest_get "$previous" images.web)
|
||||
echo '[release] restoring previous application images' >&2
|
||||
activate_images "$previous_api" "$previous_web"
|
||||
verify_runtime
|
||||
}
|
||||
|
||||
backup_database() {
|
||||
local source_sha=$1
|
||||
local temp_backup=$BACKUP_DIR/.${source_sha}.dump.tmp
|
||||
local final_backup=$BACKUP_DIR/${source_sha}.dump
|
||||
|
||||
umask 077
|
||||
"${compose[@]}" exec -T "$POSTGRES_SERVICE" \
|
||||
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DATABASE" -Fc >"$temp_backup"
|
||||
[[ -s $temp_backup ]] || { echo 'database backup is empty' >&2; rm -f "$temp_backup"; return 1; }
|
||||
"${compose[@]}" exec -T "$POSTGRES_SERVICE" pg_restore -l <"$temp_backup" >/dev/null
|
||||
mv "$temp_backup" "$final_backup"
|
||||
mapfile -t expired < <(find "$BACKUP_DIR" -maxdepth 1 -type f -name '*.dump' -printf '%T@ %p\n' | sort -rn | awk -v keep="$BACKUP_RETENTION" 'NR > keep { sub(/^[^ ]+ /, ""); print }')
|
||||
if [[ ${#expired[@]} -gt 0 ]]; then
|
||||
rm -f -- "${expired[@]}"
|
||||
fi
|
||||
echo "[release] verified database backup: $final_backup"
|
||||
}
|
||||
|
||||
record_current() {
|
||||
local manifest=$1
|
||||
local action=$2
|
||||
local started_at=$3
|
||||
local source_sha recorded temp
|
||||
source_sha=$(manifest_get "$manifest" sourceSha)
|
||||
recorded=$RELEASES_DIR/$source_sha.json
|
||||
temp=$RELEASES_DIR/.${source_sha}.json.tmp
|
||||
rm -f "$temp"
|
||||
RELEASE_DEPLOYED_AT=$(date -u '+%Y-%m-%dT%H:%M:%SZ') \
|
||||
RELEASE_DEPLOY_DURATION_SECONDS=$(( $(date +%s) - started_at )) \
|
||||
RELEASE_DEPLOY_ACTION=$action \
|
||||
node "$manifest_tool" record-deployment "$manifest" "$temp" >/dev/null
|
||||
install -m 0600 "$temp" "$recorded"
|
||||
install -m 0600 "$recorded" "$current_manifest.tmp"
|
||||
mv "$current_manifest.tmp" "$current_manifest"
|
||||
rm -f "$temp"
|
||||
}
|
||||
|
||||
deploy_manifest() {
|
||||
local manifest=$1
|
||||
local action=${2:-deploy}
|
||||
local source_sha base_sha api_image web_image migrations_changed current_sha
|
||||
local changed_components api_changed=false web_changed=false
|
||||
local previous_copy=
|
||||
local started_at
|
||||
|
||||
[[ -f $manifest && ! -L $manifest ]] || { echo 'manifest must be a regular file' >&2; return 1; }
|
||||
node "$manifest_tool" validate "$manifest"
|
||||
source_sha=$(manifest_get "$manifest" sourceSha)
|
||||
base_sha=$(manifest_get "$manifest" baseReleaseSha)
|
||||
api_image=$(manifest_get "$manifest" images.api)
|
||||
web_image=$(manifest_get "$manifest" images.web)
|
||||
migrations_changed=$(manifest_get "$manifest" migrationsChanged)
|
||||
changed_components=$(manifest_get "$manifest" components)
|
||||
[[ $changed_components == *'"api"'* ]] && api_changed=true
|
||||
[[ $changed_components == *'"web"'* ]] && web_changed=true
|
||||
|
||||
if [[ $action == deploy ]]; then
|
||||
if [[ -f $current_manifest ]]; then
|
||||
current_sha=$(manifest_get "$current_manifest" sourceSha)
|
||||
[[ $current_sha == "$base_sha" ]] || {
|
||||
echo "stale release manifest: production=$current_sha manifest_base=$base_sha" >&2
|
||||
return 1
|
||||
}
|
||||
else
|
||||
[[ -z $base_sha ]] || { echo 'non-bootstrap manifest has no production base' >&2; return 1; }
|
||||
fi
|
||||
fi
|
||||
|
||||
validate_image "$api_image" "$source_sha" "$api_changed"
|
||||
validate_image "$web_image" "$source_sha" "$web_changed"
|
||||
|
||||
if [[ -f $current_manifest ]]; then
|
||||
previous_copy=$(mktemp "$RELEASES_DIR/.previous.XXXXXX")
|
||||
install -m 0600 "$current_manifest" "$previous_copy"
|
||||
fi
|
||||
started_at=$(date +%s)
|
||||
|
||||
if [[ $action == deploy && $migrations_changed == true ]]; then
|
||||
backup_database "$source_sha"
|
||||
export AI_GATEWAY_API_IMAGE=$api_image
|
||||
export AI_GATEWAY_WEB_IMAGE=$web_image
|
||||
"${compose[@]}" run --rm --no-deps migrator
|
||||
fi
|
||||
|
||||
if ! activate_images "$api_image" "$web_image" || ! verify_runtime; then
|
||||
if [[ -n $previous_copy ]]; then
|
||||
restore_previous "$previous_copy" || echo 'automatic application rollback also failed' >&2
|
||||
fi
|
||||
rm -f "$previous_copy"
|
||||
return 1
|
||||
fi
|
||||
|
||||
record_current "$manifest" "$action" "$started_at"
|
||||
rm -f "$previous_copy"
|
||||
echo "production_release=PASS action=$action release=$source_sha"
|
||||
}
|
||||
|
||||
case ${1:-} in
|
||||
status)
|
||||
[[ $# -eq 1 ]] || exit 64
|
||||
[[ -f $current_manifest ]] || { echo 'no production release manifest' >&2; exit 1; }
|
||||
node "$manifest_tool" validate "$current_manifest" >/dev/null
|
||||
cat "$current_manifest"
|
||||
;;
|
||||
deploy)
|
||||
[[ $# -eq 2 ]] || exit 64
|
||||
deploy_manifest "$2" deploy
|
||||
;;
|
||||
rollback)
|
||||
[[ $# -eq 2 && $2 =~ ^[0-9a-f]{40}$ ]] || exit 64
|
||||
target=$RELEASES_DIR/$2.json
|
||||
[[ -f $target && ! -L $target ]] || { echo 'unknown historical release' >&2; exit 1; }
|
||||
deploy_manifest "$target" rollback
|
||||
;;
|
||||
*)
|
||||
echo 'usage: easyai-ai-gateway-release {status|deploy <manifest>|rollback <sha>}' >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,12 @@
|
||||
# Install as /etc/easyai-ai-gateway-release.conf with mode 0600 and root ownership.
|
||||
DEPLOY_DIR=/root/easyai-ai-gateway-deploy
|
||||
COMPOSE_FILE=/root/easyai-ai-gateway-deploy/docker-compose.yml
|
||||
RELEASES_DIR=/root/easyai-ai-gateway-deploy/.releases
|
||||
BACKUP_DIR=/var/backups/easyai-ai-gateway
|
||||
PUBLIC_BASE_URL=https://ai.51easyai.com
|
||||
API_PORT=8088
|
||||
WEB_PORT=5178
|
||||
POSTGRES_SERVICE=postgres
|
||||
POSTGRES_USER=easyai
|
||||
POSTGRES_DATABASE=easyai_ai_gateway
|
||||
BACKUP_RETENTION=5
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
[[ $(id -u) -eq 0 ]] || { echo 'run as root on the production server' >&2; exit 1; }
|
||||
|
||||
install -d -m 0755 /usr/local/lib/easyai-ai-gateway-release
|
||||
install -m 0755 "$root/deploy/manual/easyai-ai-gateway-release" \
|
||||
/usr/local/sbin/easyai-ai-gateway-release
|
||||
install -m 0644 "$root/scripts/release-manifest.mjs" \
|
||||
/usr/local/lib/easyai-ai-gateway-release/release-manifest.mjs
|
||||
if [[ ! -e /etc/easyai-ai-gateway-release.conf ]]; then
|
||||
install -m 0600 "$root/deploy/manual/easyai-ai-gateway-release.conf.example" \
|
||||
/etc/easyai-ai-gateway-release.conf
|
||||
echo 'created /etc/easyai-ai-gateway-release.conf; review it before the first release'
|
||||
fi
|
||||
|
||||
echo 'installed manual release helper; no service, timer, webhook, or watcher was enabled'
|
||||
Reference in New Issue
Block a user