263 lines
11 KiB
YAML
263 lines
11 KiB
YAML
# =============================================================================
|
||
# AWOOOI CD Pipeline - 開發環境 (dev branch)
|
||
# =============================================================================
|
||
# 流程: Build → Push to Harbor (dev tag) → Deploy to awoooi-dev namespace
|
||
# 用途: 驗證修改,確認無誤後才 merge main → 觸發正式環境部署
|
||
# 2026-04-01 ogt: 建立開發環境 CI/CD 分離機制
|
||
|
||
name: CD Pipeline (Dev)
|
||
|
||
on:
|
||
push:
|
||
branches: [dev]
|
||
workflow_dispatch:
|
||
|
||
concurrency:
|
||
group: cd-dev-deploy-${{ github.ref }}
|
||
cancel-in-progress: false
|
||
|
||
env:
|
||
HARBOR: 192.168.0.110:5000
|
||
HARBOR_MIRROR: 192.168.0.110:5001
|
||
SRE_GROUP_CHAT_ID: "-1003711974679"
|
||
OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.0.188:24318
|
||
OTEL_SERVICE_NAME: awoooi-cd-dev
|
||
OTEL_RESOURCE_ATTRIBUTES: service.version=${{ github.sha }},deployment.environment=dev
|
||
|
||
jobs:
|
||
build-and-deploy-dev:
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
|
||
- name: Get Commit Info
|
||
id: commit
|
||
run: |
|
||
echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||
echo "message=$(git log -1 --pretty=%s | head -c 50)" >> $GITHUB_OUTPUT
|
||
echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT
|
||
|
||
- name: Notify Dev Deploy Start
|
||
run: |
|
||
MSG="🔧 <b>[DEV] 部署開始</b>
|
||
├ 📝 ${{ steps.commit.outputs.message }}
|
||
├ 🔖 <code>${{ steps.commit.outputs.short_sha }}</code>
|
||
└ 🌿 dev branch"
|
||
if AWOOI_CICD_STATUS=running \
|
||
AWOOI_CICD_STAGE=dev-deploy \
|
||
AWOOI_CICD_JOB_NAME="[DEV] 部署開始" \
|
||
AWOOI_CICD_COMMIT_SHA="${GITHUB_SHA}" \
|
||
AWOOI_CICD_SUMMARY="${{ steps.commit.outputs.message }}" \
|
||
scripts/ci/notify-awoooi-cicd.sh; then
|
||
echo "Dev deploy start notification mirrored through AWOOI API"
|
||
else
|
||
printf '%b' "$MSG" | curl -fS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||
-d "chat_id=${{ env.SRE_GROUP_CHAT_ID }}" \
|
||
-d "parse_mode=HTML" \
|
||
--data-urlencode "text@-"
|
||
fi
|
||
|
||
# API 測試 (同 prod CI,確保 dev 也通過)
|
||
- name: Run API Tests
|
||
run: |
|
||
VENV=/opt/api-venv
|
||
HASH_FILE=/opt/api-venv/.deps_hash
|
||
CURRENT_HASH=$(md5sum apps/api/pyproject.toml | awk '{print $1}')
|
||
|
||
if [ ! -d "$VENV" ] || [ "$(cat $HASH_FILE 2>/dev/null)" != "$CURRENT_HASH" ]; then
|
||
python3 -m venv $VENV
|
||
source $VENV/bin/activate
|
||
pip install -q uv
|
||
cd apps/api && uv pip install -q -e ".[dev]" && cd -
|
||
echo "$CURRENT_HASH" > $HASH_FILE
|
||
else
|
||
source $VENV/bin/activate
|
||
fi
|
||
|
||
cd apps/api
|
||
# 2026-04-22 ogt: DATABASE_URL 改為必填,單元測試需要此 env var 讓 Settings 通過驗證
|
||
DATABASE_URL="${DATABASE_URL:-postgresql+asyncpg://ci:ci@localhost/ci}" \
|
||
pytest tests/ -v --tb=short -x \
|
||
--ignore=tests/test_anomaly_counter.py \
|
||
--ignore=tests/test_global_repair_cooldown.py \
|
||
--ignore=tests/test_redis_multisig.py \
|
||
--ignore=tests/test_model_regression.py \
|
||
--ignore=tests/test_prompt_validation.py \
|
||
2>&1 | tail -50
|
||
echo "✅ API 測試通過"
|
||
|
||
- name: Login to Harbor
|
||
run: |
|
||
HARBOR_USERNAME="$(cat <<'AWOOOI_SECRET_HARBOR_USERNAME'
|
||
${{ secrets.HARBOR_USERNAME }}
|
||
AWOOOI_SECRET_HARBOR_USERNAME
|
||
)"
|
||
HARBOR_PASSWORD="$(cat <<'AWOOOI_SECRET_HARBOR_PASSWORD'
|
||
${{ secrets.HARBOR_PASSWORD }}
|
||
AWOOOI_SECRET_HARBOR_PASSWORD
|
||
)"
|
||
printf '%s' "$HARBOR_PASSWORD" | docker login "${{ env.HARBOR }}" \
|
||
-u "$HARBOR_USERNAME" \
|
||
--password-stdin
|
||
|
||
# Dev API 鏡像:強制重建,不用 cache(確保 models.json 等配置文件更新)
|
||
- name: Build and Push API (Dev)
|
||
run: |
|
||
docker build -f apps/api/Dockerfile \
|
||
--no-cache \
|
||
-t ${{ env.HARBOR }}/awoooi/api:dev-${{ github.sha }} \
|
||
-t ${{ env.HARBOR }}/awoooi/api:dev-latest \
|
||
.
|
||
docker push ${{ env.HARBOR }}/awoooi/api:dev-${{ github.sha }}
|
||
docker push ${{ env.HARBOR }}/awoooi/api:dev-latest
|
||
echo "✅ Dev API 鏡像建置完成"
|
||
|
||
# 注入 Dev K8s Secrets
|
||
- name: Inject Dev K8s Secrets
|
||
run: |
|
||
secret_b64() {
|
||
python3 -c 'import base64, sys; data=sys.stdin.buffer.read(); data=data[:-1] if data.endswith(b"\n") else data; sys.stdout.write(base64.b64encode(data).decode())'
|
||
}
|
||
write_deploy_key() {
|
||
mkdir -p ~/.ssh
|
||
umask 077
|
||
cat > ~/.ssh/deploy_key <<'AWOOOI_DEPLOY_KEY'
|
||
${{ secrets.DEPLOY_SSH_KEY }}
|
||
AWOOOI_DEPLOY_KEY
|
||
chmod 600 ~/.ssh/deploy_key
|
||
}
|
||
TG_BOT_TOKEN_B64="$(secret_b64 <<'AWOOOI_SECRET_TG_BOT_TOKEN'
|
||
${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||
AWOOOI_SECRET_TG_BOT_TOKEN
|
||
)"
|
||
TG_CHAT_ID_B64="$(secret_b64 <<'AWOOOI_SECRET_SRE_GROUP_CHAT_ID_COMPAT'
|
||
${{ secrets.SRE_GROUP_CHAT_ID }}
|
||
AWOOOI_SECRET_SRE_GROUP_CHAT_ID_COMPAT
|
||
)"
|
||
NVIDIA_API_KEY_B64="$(secret_b64 <<'AWOOOI_SECRET_NVIDIA_API_KEY'
|
||
${{ secrets.NVIDIA_API_KEY }}
|
||
AWOOOI_SECRET_NVIDIA_API_KEY
|
||
)"
|
||
GEMINI_API_KEY_B64="$(secret_b64 <<'AWOOOI_SECRET_GEMINI_API_KEY'
|
||
${{ secrets.GEMINI_API_KEY }}
|
||
AWOOOI_SECRET_GEMINI_API_KEY
|
||
)"
|
||
|
||
mkdir -p ~/.ssh
|
||
write_deploy_key
|
||
ssh-keyscan -T 5 -t ed25519,rsa,ecdsa 192.168.0.120 > "${HOME}/.ssh/known_hosts" 2>/dev/null
|
||
test -s "${HOME}/.ssh/known_hosts" || { echo "❌ K8S host keyscan failed: 192.168.0.120"; exit 1; }
|
||
SSH_OPTS="-o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${HOME}/.ssh/known_hosts -i ~/.ssh/deploy_key"
|
||
# 2026-05-05 Codex: kubectl runs on 120 control-plane. 121 is a
|
||
# worker and its local kubeconfig points at 127.0.0.1:6443.
|
||
ssh $SSH_OPTS wooo@192.168.0.120 << SECRETS
|
||
set -e
|
||
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
||
|
||
sudo kubectl patch secret awoooi-secrets -n awoooi-dev --type='json' -p='[
|
||
{"op":"replace","path":"/data/OPENCLAW_TG_BOT_TOKEN","value":"${TG_BOT_TOKEN_B64}"},
|
||
{"op":"replace","path":"/data/OPENCLAW_TG_CHAT_ID","value":"${TG_CHAT_ID_B64}"}
|
||
]' || echo "⚠️ Telegram Secrets patch 跳過"
|
||
|
||
if [ -n "${NVIDIA_API_KEY_B64}" ]; then
|
||
sudo kubectl patch secret awoooi-secrets -n awoooi-dev --type='json' -p='[
|
||
{"op":"replace","path":"/data/NVIDIA_API_KEY","value":"${NVIDIA_API_KEY_B64}"}
|
||
]' && echo "✅ NVIDIA_API_KEY 已注入 dev"
|
||
fi
|
||
|
||
if [ -n "${GEMINI_API_KEY_B64}" ]; then
|
||
sudo kubectl patch secret awoooi-secrets -n awoooi-dev --type='json' -p='[
|
||
{"op":"replace","path":"/data/GEMINI_API_KEY","value":"${GEMINI_API_KEY_B64}"}
|
||
]' && echo "✅ GEMINI_API_KEY 已注入 dev"
|
||
fi
|
||
|
||
echo "✅ Dev Secrets 注入完成"
|
||
SECRETS
|
||
|
||
# 部署到 awoooi-dev
|
||
- name: Deploy to Dev K8s
|
||
run: |
|
||
ssh-keyscan -T 5 -t ed25519,rsa,ecdsa 192.168.0.120 > "${HOME}/.ssh/known_hosts" 2>/dev/null
|
||
test -s "${HOME}/.ssh/known_hosts" || { echo "❌ K8S host keyscan failed: 192.168.0.120"; exit 1; }
|
||
SSH_OPTS="-o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${HOME}/.ssh/known_hosts -i ~/.ssh/deploy_key"
|
||
cat k8s/awoooi-dev/02-configmap.yaml | \
|
||
ssh $SSH_OPTS wooo@192.168.0.120 \
|
||
"export KUBECONFIG=/etc/rancher/k3s/k3s.yaml && sudo kubectl apply -f -"
|
||
|
||
ssh $SSH_OPTS wooo@192.168.0.120 << 'DEPLOY'
|
||
set -e
|
||
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
||
|
||
sudo kubectl set image deployment/awoooi-api \
|
||
api=192.168.0.110:5000/awoooi/api:dev-${{ github.sha }} \
|
||
-n awoooi-dev
|
||
|
||
sudo kubectl rollout status deployment/awoooi-api -n awoooi-dev --timeout=120s
|
||
echo "✅ Dev 部署完成"
|
||
|
||
# Health Check
|
||
sleep 10
|
||
HEALTH_PASS=0
|
||
for i in 1 2 3; do
|
||
HTTP_CODE=$(curl -s -w "%{http_code}" -o /dev/null --connect-timeout 10 "http://localhost:32344/api/v1/health")
|
||
if [ "$HTTP_CODE" = "200" ]; then
|
||
echo "✅ Dev API 健康檢查通過 (port 32344)"
|
||
HEALTH_PASS=1
|
||
break
|
||
fi
|
||
echo "⏳ 嘗試 #$i: HTTP $HTTP_CODE,等待 10s..."
|
||
sleep 10
|
||
done
|
||
if [ "$HEALTH_PASS" = "0" ]; then
|
||
echo "❌ Dev API 健康檢查失敗"
|
||
exit 1
|
||
fi
|
||
DEPLOY
|
||
|
||
- name: Notify Dev Deploy Success
|
||
run: |
|
||
END_TIME=$(date +%s)
|
||
DURATION=$((END_TIME - ${{ steps.commit.outputs.start_time }}))
|
||
MINUTES=$((DURATION / 60))
|
||
SECONDS=$((DURATION % 60))
|
||
MSG="✅ <b>[DEV] 部署完成</b>
|
||
├ 📝 ${{ steps.commit.outputs.message }}
|
||
├ 🔖 <code>${{ steps.commit.outputs.short_sha }}</code>
|
||
├ ⏱️ 耗時: ${MINUTES}m ${SECONDS}s
|
||
└ 🩺 http://192.168.0.125:32344/api/v1/health"
|
||
if AWOOI_CICD_STATUS=success \
|
||
AWOOI_CICD_STAGE=dev-deploy \
|
||
AWOOI_CICD_JOB_NAME="[DEV] 部署完成" \
|
||
AWOOI_CICD_COMMIT_SHA="${GITHUB_SHA}" \
|
||
AWOOI_CICD_DURATION_SECONDS="${DURATION}" \
|
||
AWOOI_CICD_SUMMARY="${{ steps.commit.outputs.message }}" \
|
||
scripts/ci/notify-awoooi-cicd.sh; then
|
||
echo "Dev deploy success notification mirrored through AWOOI API"
|
||
else
|
||
printf '%b' "$MSG" | curl -fS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||
-d "chat_id=${{ env.SRE_GROUP_CHAT_ID }}" \
|
||
-d "parse_mode=HTML" \
|
||
--data-urlencode "text@-"
|
||
fi
|
||
|
||
- name: Notify Dev Deploy Failure
|
||
if: failure()
|
||
run: |
|
||
MSG="❌ <b>[DEV] 部署失敗</b>
|
||
├ 📝 ${{ steps.commit.outputs.message }}
|
||
├ 🔖 <code>${{ steps.commit.outputs.short_sha }}</code>
|
||
└ 🔗 <a href=\"http://192.168.0.110:3001/wooo/awoooi/actions\">查看日誌</a>"
|
||
if AWOOI_CICD_STATUS=failed \
|
||
AWOOI_CICD_STAGE=dev-deploy \
|
||
AWOOI_CICD_JOB_NAME="[DEV] 部署失敗" \
|
||
AWOOI_CICD_COMMIT_SHA="${GITHUB_SHA}" \
|
||
AWOOI_CICD_SUMMARY="${{ steps.commit.outputs.message }}" \
|
||
scripts/ci/notify-awoooi-cicd.sh; then
|
||
echo "Dev deploy failure notification mirrored through AWOOI API"
|
||
else
|
||
printf '%b' "$MSG" | curl -fS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||
-d "chat_id=${{ env.SRE_GROUP_CHAT_ID }}" \
|
||
-d "parse_mode=HTML" \
|
||
--data-urlencode "text@-"
|
||
fi
|