Initial commit with 2026 World Cup Quant Platform core modules and CI/CD
This commit is contained in:
35
ops/deploy-host-assessment.md
Normal file
35
ops/deploy-host-assessment.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 192.168.0.XXX 主機快速評估與正式上線建議
|
||||
|
||||
## 目前可觀測結果(已完成)
|
||||
|
||||
檢查指標:
|
||||
- ping 可達性
|
||||
- SSH(22)/HTTP(80) 開放
|
||||
- HTTP 回應(首頁)
|
||||
- 若你要正式流量,另外請再補 `CPU/MEM/磁碟/I/O/端口衝突/服務穩定性` 實測
|
||||
|
||||
結果:
|
||||
- `192.168.0.110`: HTTP 301(可能有既有反向代理/導向)
|
||||
- `192.168.0.188`: HTTP 200(直接可回應)
|
||||
- `192.168.0.120`: port 80 未開
|
||||
- `192.168.0.121`: port 80 未開
|
||||
|
||||
對外正式網址建議:`2026fifa.wooo.work`
|
||||
|
||||
## 建議
|
||||
|
||||
- **主站上線:`192.168.0.188`**
|
||||
- 優先原因:已能直接回 HTTP 200,網頁入口可立即承接。
|
||||
- 作為正式環境主節點建議。
|
||||
- 備援方案:
|
||||
- `192.168.0.110` 可作為備援節點,需先確認 301 重導目標與既有服務衝突後再掛上。
|
||||
- `120` / `121` 建議先做服務預配置(開放 80/443 + 反代),再參與正式流量。
|
||||
|
||||
## 正式上線前最小實測
|
||||
|
||||
1. CPU 平均負載、記憶體、磁碟 I/O、開放端口檢查
|
||||
2. 24 小時壓測與 API 快速刷新測試(以 20~60 秒輪詢為基準)
|
||||
3. `The Odds API` 呼叫量與速率配額驗證
|
||||
4. 新聞源可用率(RSS/NewsAPI)失敗 fallback 驗證
|
||||
5. 自動重啟(PM2)與 crash 回復演練
|
||||
6. 回滾流程:新版本失敗 2 分鐘內可回到上版前容器
|
||||
62
ops/deploy-production.sh
Executable file
62
ops/deploy-production.sh
Executable file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${1:-192.168.0.188}"
|
||||
PROJECT_ROOT="${2:-/opt/fifa2026}"
|
||||
PUBLIC_ORIGIN="${3:-https://2026fifa.wooo.work}"
|
||||
DEPLOY_USER="${DEPLOY_USER:-${USER}}"
|
||||
LOCAL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
ENV_FILE="${LOCAL_DIR}/.env"
|
||||
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
echo "[deploy] ERROR: .env not found at ${ENV_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[deploy] target=${HOST} path=${PROJECT_ROOT}"
|
||||
echo "[deploy] rsync source=${LOCAL_DIR}"
|
||||
|
||||
ssh -o StrictHostKeyChecking=accept-new "${DEPLOY_USER}@${HOST}" "mkdir -p ${PROJECT_ROOT}/shared ${PROJECT_ROOT}/logs"
|
||||
|
||||
rsync -avz --delete \
|
||||
--exclude node_modules \
|
||||
--exclude .git \
|
||||
--exclude .env \
|
||||
"${LOCAL_DIR}/" "${DEPLOY_USER}@${HOST}:${PROJECT_ROOT}/current/"
|
||||
|
||||
scp "${ENV_FILE}" "${DEPLOY_USER}@${HOST}:${PROJECT_ROOT}/.env"
|
||||
|
||||
ssh "${DEPLOY_USER}@${HOST}" "PROJECT_ROOT='${PROJECT_ROOT}'; \
|
||||
mkdir -p ${PROJECT_ROOT}/current; \
|
||||
cd ${PROJECT_ROOT}/current; \
|
||||
if [[ -f .env ]]; then :; else cp ${PROJECT_ROOT}/.env .env; fi; \
|
||||
if ! grep -q '^APP_PUBLIC_ORIGIN=' ${PROJECT_ROOT}/.env; then \
|
||||
echo \"APP_PUBLIC_ORIGIN=${PUBLIC_ORIGIN}\" >> ${PROJECT_ROOT}/.env; \
|
||||
fi; \
|
||||
if ! grep -q '^APP_TIME_ZONE=' ${PROJECT_ROOT}/.env; then \
|
||||
echo 'APP_TIME_ZONE=Asia/Taipei' >> ${PROJECT_ROOT}/.env; \
|
||||
fi; \
|
||||
if ! grep -q '^TZ=' ${PROJECT_ROOT}/.env; then \
|
||||
echo 'TZ=Asia/Taipei' >> ${PROJECT_ROOT}/.env; \
|
||||
fi; \
|
||||
if ! grep -q '^KELLY_SCALE=' ${PROJECT_ROOT}/.env; then \
|
||||
echo 'KELLY_SCALE=0.6' >> ${PROJECT_ROOT}/.env; \
|
||||
fi; \
|
||||
node -v >/tmp/node-version.txt 2>/dev/null || true; \
|
||||
if ! command -v npm >/dev/null; then \
|
||||
echo '[deploy] ERROR: npm not found'; exit 1; \
|
||||
fi; \
|
||||
npm install --omit=dev; \
|
||||
if ! command -v pm2 >/dev/null; then \
|
||||
npm install pm2 >/tmp/pm2-install.log 2>&1; \
|
||||
fi; \
|
||||
PM2_BIN=\"\$(command -v pm2 || true)\"; \
|
||||
if [ -z \"\${PM2_BIN}\" ]; then \
|
||||
PM2_BIN=\"\${PROJECT_ROOT}/current/node_modules/.bin/pm2\"; \
|
||||
fi; \
|
||||
[ -x \"\${PM2_BIN}\" ] || PM2_BIN=\"\${PROJECT_ROOT}/current/node_modules/.bin/pm2\"; \
|
||||
[ -x \"\${PM2_BIN}\" ] || (echo '[deploy] ERROR: pm2 not found' && exit 1); \
|
||||
TZ=Asia/Taipei APP_TIME_ZONE=Asia/Taipei NODE_ENV=production \"\${PM2_BIN}\" startOrReload \"\${PROJECT_ROOT}/current/ops/pm2.config.js\" --env production 2>&1 | cat; \
|
||||
\"\${PM2_BIN}\" save"
|
||||
|
||||
echo "[deploy] deploy done"
|
||||
62
ops/healthcheck-production.sh
Executable file
62
ops/healthcheck-production.sh
Executable file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${1:-192.168.0.188}"
|
||||
PORT="${2:-3108}"
|
||||
PUBLIC_HOST="${3:-2026fifa.wooo.work}"
|
||||
|
||||
normalize_host() {
|
||||
local host="$1"
|
||||
if [[ "$host" == http://* ]] || [[ "$host" == https://* ]]; then
|
||||
printf '%s' "$host"
|
||||
return
|
||||
fi
|
||||
printf 'https://%s' "$host"
|
||||
}
|
||||
|
||||
PUBLIC_URL="$(normalize_host "$PUBLIC_HOST")"
|
||||
[[ "$PUBLIC_URL" == */ ]] && PUBLIC_URL="${PUBLIC_URL%/}"
|
||||
PUBLIC_URL="${PUBLIC_URL}/api/health"
|
||||
INTERNAL_URL="http://${HOST}:${PORT}/api/health"
|
||||
|
||||
echo "[health] check public=${PUBLIC_URL}"
|
||||
echo "[health] check internal=${INTERNAL_URL} (fallback)"
|
||||
for i in {1..30}; do
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
data=""
|
||||
if resp="$(curl -sS --max-time 6 "${PUBLIC_URL}")" && echo "${resp}" | jq -e '.status' >/dev/null 2>&1; then
|
||||
data="${resp}"
|
||||
elif resp="$(curl -sS --max-time 6 "${INTERNAL_URL}")" && echo "${resp}" | jq -e '.status' >/dev/null 2>&1; then
|
||||
data="${resp}"
|
||||
fi
|
||||
|
||||
if [[ -z "${data}" ]]; then
|
||||
sleep 4
|
||||
continue
|
||||
fi
|
||||
status="$(echo "${data}" | jq -r '.status')"
|
||||
count="$(echo "${data}" | jq -r '.matchCount')"
|
||||
publicOrigin="$(echo "${data}" | jq -r '.publicOrigin // "-"')"
|
||||
tz="$(echo "${data}" | jq -r '.timeZone // "unknown"')"
|
||||
updated="$(echo "${data}" | jq -r '.lastUpdatedTaipei // "-"')"
|
||||
echo "[health] #${i} status=${status} matches=${count} public=${publicOrigin} timezone=${tz} updated=${updated}"
|
||||
if [[ "${status}" == "ready" && "${count}" != "0" ]]; then
|
||||
echo "[health] production api ready"
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
code="$(curl -o /dev/null -sS --max-time 6 -w '%{http_code}' "${PUBLIC_URL}")"
|
||||
if [[ "${code}" != "200" ]]; then
|
||||
code="$(curl -o /dev/null -sS --max-time 6 -w '%{http_code}' "${INTERNAL_URL}")"
|
||||
fi
|
||||
echo "[health] #${i} http=${code}"
|
||||
if [[ "${code}" == "200" ]]; then
|
||||
echo "[health] production endpoint returns ok"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
sleep 4
|
||||
done
|
||||
|
||||
echo "[health] ERROR: not healthy"
|
||||
exit 1
|
||||
37
ops/nginx-2026fifa.conf
Normal file
37
ops/nginx-2026fifa.conf
Normal file
@@ -0,0 +1,37 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name 2026fifa.wooo.work;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name 2026fifa.wooo.work;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/2026fifa.wooo.work/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/2026fifa.wooo.work/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
add_header X-Frame-Options SAMEORIGIN;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header Referrer-Policy same-origin;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
}
|
||||
|
||||
client_max_body_size 16m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3108;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
}
|
||||
25
ops/pm2.config.js
Normal file
25
ops/pm2.config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'fifa2026-betting-desk',
|
||||
script: './src/server.js',
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
TZ: 'Asia/Taipei',
|
||||
APP_TIME_ZONE: 'Asia/Taipei',
|
||||
APP_PUBLIC_ORIGIN: 'https://2026fifa.wooo.work',
|
||||
},
|
||||
env_file: '/opt/fifa2026/.env',
|
||||
out_file: '/opt/fifa2026/logs/out.log',
|
||||
error_file: '/opt/fifa2026/logs/error.log',
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
max_memory_restart: '700M',
|
||||
max_restarts: 15,
|
||||
restart_delay: 2000,
|
||||
merge_logs: true,
|
||||
watch: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user