diff --git a/.claude/skills/d8d-alipay-preview/SKILL.md b/.claude/skills/d8d-alipay-preview/SKILL.md new file mode 100644 index 0000000..7b9ca14 --- /dev/null +++ b/.claude/skills/d8d-alipay-preview/SKILL.md @@ -0,0 +1,69 @@ +--- +name: d8d-alipay-preview +description: 支付宝小程序构建+预览+上传体验版。支持 minidev preview 生成预览二维码和 minidev upload 上传提审版本并设置体验版。 +--- + +## Agent 执行方式 + +本技能运行在 D8D Agent 环境中,通过 d8d-platform MCP 工具操作远程沙箱容器。 +所有文件操作使用以下 MCP 工具: +- `exec_command(containerId, command)` — 在容器内执行命令 +- `read_file(containerId, path)` — 读取容器内文件 +- `write_file(containerId, path, content)` — 写入容器内文件 +- `list_directory(containerId, path)` — 列出容器目录 + +# 支付宝小程序预览与上传 + +一键构建支付宝小程序,支持两个模式: +1. **preview 预览** — 生成真机扫码预览二维码,快速验证效果 +2. **upload 上传** — 上传版本到开放平台并设为体验版,供提审或团队体验 + +## 适用场景 + +- 代码修改后快速验证真机效果 → preview +- 需要生成预览二维码给测试人员 → preview +- 功能完成后上传体验版给团队验收 → upload +- 准备提交审核前上传正式版本 → upload + +## 前置条件 + +- 项目包含 `packages/alipay-miniapp/` 目录 +- 容器内已安装 minidev 依赖(`devDependencies.minidev`) +- minidev 身份密钥已配置在 `~/.minidev/config.json` +- 支付宝小程序 AppID 已配置在 `manifest.json` + +## 执行流程 + +加载本技能后 MUST 立即读取 [workflow.md](workflow.md) 按步骤执行。 + +## 关键参数 + +| 参数 | 值 | 来源 | +|------|------|------| +| 项目路径 | `packages/alipay-miniapp` | 项目结构 | +| 构建命令 | `pnpm run build:mp-alipay` | package.json scripts | +| 产物路径 | `dist/build/mp-alipay` | uni-app 默认输出 | +| AppID | `2021003141646173` | manifest.json mp-alipay.appid | +| 身份密钥 | `~/.minidev/config.json` | minidev CLI 配置 | + +## 模式路由 + +根据用户意图自动选择模式: + +| 用户意图 | 模式 | 命令 | +|---------|------|------| +| "预览" / "preview" / "二维码" | preview | `minidev preview` | +| "上传" / "upload" / "体验版" / "提审" | upload | `minidev upload --experience` | + +如果用户未明确指定,默认执行 preview 模式。 + +## 触发方式 + +当用户消息匹配以下意图时加载本技能: +- "预览支付宝小程序" / "支付宝预览" / "minidev 预览" +- "上传支付宝小程序" / "支付宝上传" / "minidev upload" +- "设置体验版" / "上传体验版" / "提审" +- "构建支付宝小程序" / "生成支付宝预览二维码" +- "alipay preview" / "alipay upload" / "预览版" / "体验版" + +⛔ 加载后 MUST 立即读取 workflow.md 按流程执行。 diff --git a/.claude/skills/d8d-alipay-preview/workflow.md b/.claude/skills/d8d-alipay-preview/workflow.md new file mode 100644 index 0000000..65d4fff --- /dev/null +++ b/.claude/skills/d8d-alipay-preview/workflow.md @@ -0,0 +1,114 @@ +# 支付宝小程序预览与上传工作流 + +## 模式路由 + +根据用户意图选择执行模式: + +- **preview**(默认):用户说"预览"、"preview"、"二维码"、"看效果" +- **upload**:用户说"上传"、"upload"、"体验版"、"提审"、"设置体验版" + +## 参数自动检测 + +``` +APPID → packages/alipay-miniapp/src/manifest.json → mp-alipay.appid +IDENTITY_KEY → ~/.minidev/config.json (存在即表示已配置) +PROJECT_DIR → packages/alipay-miniapp +DIST_DIR → packages/alipay-miniapp/dist/build/mp-alipay +``` + +## Step 1: 环境检查 + +验证项目结构和依赖是否就绪: + +```bash +cd /home/d8d-claude/workspace +ls packages/alipay-miniapp/package.json +ls ~/.minidev/config.json +``` + +- `~/.minidev/config.json` 不存在 → 提示用户先配置 minidev 身份密钥 +- `packages/alipay-miniapp/` 不存在 → 提示当前项目不包含支付宝小程序 + +## Step 2: 构建小程序 + +```bash +cd /home/d8d-claude/workspace/packages/alipay-miniapp && pnpm run build:mp-alipay +``` + +- 超时:120000ms +- 构建失败 → 检查错误日志报告用户 +- 成功后产物在 `dist/build/mp-alipay/` + +## Step 3: 执行(preview 或 upload) + +### 3A: preview — 生成预览二维码 + +```bash +cd /home/d8d-claude/workspace/packages/alipay-miniapp && npx minidev preview --app-id 2021003141646173 --project dist/build/mp-alipay --identity-key-path /home/d8d-claude/.minidev/config.json +``` + +- 超时:180000ms +- 云端构建,耗时约 30-60 秒 + +输出提取: +- **预览二维码链接**:`https://mobilecodec.alipay.com/show.htm?code=XXXXX` +- **预览版本号**:`0.1.YYMMDDHHMM.NN` + +展示格式: +``` +✅ 预览构建成功 +📱 预览链接:https://mobilecodec.alipay.com/show.htm?code=XXXXX +📦 版本号:0.1.YYMMDDHHMM.NN +👉 用支付宝 App 扫码体验 +``` + +### 3B: upload — 上传版本并设置体验版 + +```bash +cd /home/d8d-claude/workspace/packages/alipay-miniapp && npx minidev upload --app-id 2021003141646173 --project dist/build/mp-alipay --identity-key-path /home/d8d-claude/.minidev/config.json --experience +``` + +关键参数: +- `--app-id`:支付宝小程序 APPID +- `--project`:构建产物目录 +- `--identity-key-path`:身份密钥路径 +- `--version`:可选,格式 x.y.z(如 0.0.1)。不提供则自动在最新版本号 patch+1 +- `--experience`:上传成功后自动设置为体验版本 + +- 超时:180000ms + +输出提取: +- **上传版本号**:`x.y.z` 格式 +- **是否设为体验版**:success/fail + +展示格式: +``` +✅ 上传成功 +📦 版本号:x.y.z +🌐 体验版:已设置 +👉 支付宝开放平台 → 版本管理 查看 +👉 团队成员可用支付宝扫码体验体验版 +``` + +如果用户指定了版本号,加上 `--version x.y.z` 参数: +```bash +npx minidev upload --app-id 2021003141646173 --project dist/build/mp-alipay --identity-key-path /home/d8d-claude/.minidev/config.json --experience --version 1.0.0 +``` + +## 错误处理 + +| 错误 | 原因 | 解决方式 | +|------|------|---------| +| `MODULE_NOT_FOUND` | 依赖未安装 | 执行 `pnpm install` 后重试 | +| 构建失败 | 代码语法错误 | 报告具体错误行号,建议修复后重试 | +| `authentication failed` | minidev 密钥过期/无效 | 提示用户更新 `~/.minidev/config.json` | +| `app not found` | AppID 不正确 | 检查 manifest.json 中的 appid | +| `version format error` | 版本号格式错误 | 版本号必须为 x.y.z 格式,x/y/z 为纯数字 | + +## 注意事项 + +- preview 每次生成新版本号,支付宝后台可查看历史预览版 +- upload 会创建正式版本记录,可在开放平台版本管理中查看 +- `--experience` 让上传后自动设为体验版,团队成员可直接扫码体验 +- 版本号格式必须为 `x.y.z`,不提供则自动 patch+1 +- 构建产物 `dist/` 目录已在 `.gitignore` 中,不会被提交到 Git diff --git a/.claude/skills/d8d-release/assets/ci.py b/.claude/skills/d8d-release/assets/ci.py new file mode 100644 index 0000000..5e03128 --- /dev/null +++ b/.claude/skills/d8d-release/assets/ci.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +""" +Gitea Actions CI 管理工具 + +用法: + ci.py status # 显示最近构建状态 + ci.py log [job_id] # 查看构建日志 + ci.py log --raw # 输出原始 JSON + ci.py log -o log.txt # 日志写入文件 + ci.py build [version] # 推送 tag 触发构建(如 build 0.2.0) + ci.py tags # 列出已推送的 tag +""" + +import subprocess +import sys +import os +import re +import json +import argparse +from pathlib import Path + +# 自动安装缺失依赖 +try: + import requests +except ImportError: + print("正在安装 python3-requests ...") + subprocess.check_call(["sudo", "apt-get", "update", "-qq"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.check_call(["sudo", "apt-get", "install", "-y", "python3-requests"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + import requests + + +def load_config(): + script_dir = Path(__file__).resolve().parent + project_dir = script_dir.parent + + # 优先从 eci.conf 读取 Gitea 配置 + eci_conf = project_dir / "eci.conf" + config = {} + for conf_file in [eci_conf]: + if conf_file.exists(): + with open(conf_file) as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + config[key.strip()] = value.strip() + + return { + "gitea_url": config.get("GITEA_URL", os.getenv("GITEA_URL", "https://gitea.d8d.fun")), + "gitea_username": config.get("GITEA_USERNAME", os.getenv("GITEA_USERNAME", "")), + "gitea_password": config.get("GITEA_PASSWORD", os.getenv("GITEA_PASSWORD", "")), + "repo_owner": config.get("GITEA_REPO_OWNER", os.getenv("REPO_OWNER", "d8dfun")), + "repo_name": config.get("GITEA_REPO_NAME", os.getenv("REPO_NAME", "")), + } + + +CONFIG = load_config() + +STATUS_ICONS = { + "success": "OK", "failure": "FAIL", "running": "...", + "waiting": "WAIT", "cancelled": "SKIP", "skipped": "SKIP", + "pending": "...", +} + + +def _time_ago(iso_str): + from datetime import datetime, timezone + try: + dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00")) + delta = datetime.now(timezone.utc) - dt + mins = int(delta.total_seconds() / 60) + if mins < 60: + return f"{mins}m ago" + hours = mins // 60 + if hours < 24: + return f"{hours}h ago" + return f"{hours // 24}d ago" + except Exception: + return "" + + +class GiteaClient: + def __init__(self): + self.session = requests.Session() + self.csrf_token = None + + def login(self): + login_url = f"{CONFIG['gitea_url']}/user/login" + resp = self.session.get(login_url) + + m = re.search(r'4} {'Status':8s} {'Branch':16s} {'Ago':10s} Title") + print(f" {'----':>4} {'------':8s} {'------':16s} {'---':10s} -----") + + for r in runs: + icon = STATUS_ICONS.get(r.get("status", ""), "?") + ago = _time_ago(r.get("run_started_at") or r.get("created_at", "")) + title = (r.get("display_title") or "")[:50] + status = r.get("status", "") + branch = r.get("head_branch", "") + print(f" #{r['run_number']:>3} {icon:8s} {branch:16s} {ago:10s} {title}") + + print(f"\n View log: python3 scripts/ci.py log ") + + +def cmd_log(args): + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("run_id") + parser.add_argument("job_id", nargs="?", default="0") + parser.add_argument("--raw", action="store_true") + parser.add_argument("-o", "--output", help="Write to file") + parsed = parser.parse_args(args) + + client = GiteaClient() + result = client.get_log(parsed.run_id, parsed.job_id) + + if isinstance(result, dict) and "error" in result: + print(f"Error: {result['error']}") + return + + if parsed.raw: + text = json.dumps(result, indent=2, ensure_ascii=False) + else: + text = _format_log(result) + + if parsed.output: + with open(parsed.output, "w", encoding="utf-8") as f: + f.write(text) + print(f"Written: {parsed.output}") + else: + print(text) + + +def _format_log(data): + run_info = data.get("state", {}).get("run", {}) + job_info = data.get("state", {}).get("currentJob", {}) + logs = data.get("logs", []) + + lines = [] + lines.append("=" * 80) + lines.append(f"Run: {run_info.get('title', 'N/A')}") + lines.append(f"Status: {run_info.get('status', 'N/A')}") + commit = run_info.get("commit", {}) + if commit: + lines.append(f"Commit: {commit.get('shortSHA', '')} Branch: {commit.get('branch', {}).get('name', '')}") + lines.append("=" * 80) + + steps = job_info.get("steps", []) + if steps: + lines.append("") + for idx, s in enumerate(steps): + icon = STATUS_ICONS.get(s.get("status", ""), "?") + lines.append(f" [{idx}] {icon:6s} {s.get('summary', '')} ({s.get('duration', '')})") + + if logs: + lines.append("") + lines.append("-" * 80) + for item in logs: + icon = STATUS_ICONS.get(item.get("status", ""), "?") + lines.append(f"\n{icon} Step {item['step']}: {item['summary']}") + entries = item.get("entries", []) + for e in entries[:200]: + msg = e.get("message", str(e)) if isinstance(e, dict) else str(e) + lines.append(f" {msg}") + if len(entries) > 200: + lines.append(f" ... {len(entries) - 200} more lines") + else: + lines.append("\nNo log content") + + return "\n".join(lines) + + +def cmd_build(args): + version = args[0] if args else None + if not version: + print("Usage: ci.py build (e.g. ci.py build 0.2.0)") + return False + + tag = f"v{version}" + print(f"Creating tag {tag} ...") + + r = subprocess.run(["git", "rev-parse", tag], capture_output=True, text=True) + if r.returncode == 0: + print(f"Tag {tag} already exists, deleting and recreating...") + subprocess.run(["git", "tag", "-d", tag], capture_output=True) + subprocess.run(["git", "push", "origin", f":refs/tags/{tag}"], capture_output=True) + + r = subprocess.run(["git", "tag", "-a", tag, "-m", f"Release {tag}"], capture_output=True, text=True) + if r.returncode != 0: + print(f"Failed to create tag: {r.stderr}") + return False + + r = subprocess.run(["git", "push", "origin", tag], capture_output=True, text=True) + if r.returncode != 0: + print(f"Failed to push tag: {r.stderr}") + subprocess.run(["git", "tag", "-d", tag], capture_output=True) + return False + + print(f"Pushed: {tag}") + print(f"View: {CONFIG['gitea_url']}/{CONFIG['repo_owner']}/{CONFIG['repo_name']}/actions") + return True + + +def cmd_tags(_args): + result = subprocess.run(["git", "tag", "-l", "v*"], capture_output=True, text=True) + tags = sorted(t for t in result.stdout.strip().split("\n") if t) + if not tags: + print("No tags found") + return + print(f"Tags ({len(tags)} total):") + for tag in tags[-20:]: + print(f" {tag}") + if len(tags) > 20: + print(f" ... showing last 20 of {len(tags)}") + print(f" Latest: {tags[-1]}") + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"): + print(__doc__) + return + + commands = { + "status": lambda a: cmd_status(a), + "log": lambda a: cmd_log(a), + "build": lambda a: cmd_build(a), + "tags": lambda a: cmd_tags(a), + } + cmd = sys.argv[1] + if cmd not in commands: + print(f"Unknown command: {cmd}\n") + print(__doc__) + return + commands[cmd](sys.argv[2:]) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/d8d-release/assets/eci-deploy.sh b/.claude/skills/d8d-release/assets/eci-deploy.sh index 32122f6..35d982b 100755 --- a/.claude/skills/d8d-release/assets/eci-deploy.sh +++ b/.claude/skills/d8d-release/assets/eci-deploy.sh @@ -1,10 +1,18 @@ #!/bin/bash -# ECI 容器组部署脚本 - 使用私有 Registry 内网拉取镜像 -# 用法: ./scripts/eci-deploy.sh +# ECI 容器组部署脚本 +# 用法: ./scripts/eci-deploy.sh # 依赖: aliyun CLI 已配置, eci.conf 存在 set -e +if [ $# -eq 0 ]; then + echo "错误: 请提供镜像标签" + echo "用法: $0 " + exit 1 +fi + +IMAGE_TAG="$1" + # 加载配置 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../eci.conf" @@ -16,66 +24,47 @@ aliyun configure set --mode AK \ --region "$ECI_REGION" echo "🚀 开始部署 ECI 容器组" +echo "📦 镜像: $ECI_REGISTRY_INTRANET_URL/d8d-user-release:$IMAGE_TAG" echo "🌐 EIP: $ECI_EIP_ADDRESS" -echo "📦 镜像仓库: 私有 Registry (内网地址: $ECI_REGISTRY_INTRANET_URL)" +echo "🏪 Registry (内网): $ECI_REGISTRY_INTRANET_URL" echo "🔐 协议: HTTP (PlainHttpRegistry)" -# NAS 目录准备:确保 postgres 数据目录存在且权限正确(UID 70 = postgres 用户) -echo "📁 准备 NAS 目录..." -NAS_FS_ID=$(echo "$ECI_NAS_SERVER" | cut -d'.' -f1) -aliyun nas CreateDir \ - --FileSystemId "$NAS_FS_ID" \ - --RootDirectory "/postgres-data" \ - --OwnerUserId 70 \ - --OwnerGroupId 70 \ - --Permission "0755" \ - --RegionId "$ECI_REGION" 2>/dev/null && echo "✅ NAS 目录 /postgres-data 已就绪" || echo "✅ NAS 目录已存在" - -# 检查并删除同名容器组(列出所有容器组后按名称匹配) +# 检查容器组是否存在 echo "🔍 检查现有容器组..." -ALL_GROUPS=$(aliyun eci DescribeContainerGroups \ - --RegionId "$ECI_REGION" 2>/dev/null || true) +EXISTING=$(aliyun eci DescribeContainerGroups \ + --RegionId "$ECI_REGION" \ + --ContainerGroupName "$ECI_CONTAINER_GROUP_NAME" 2>/dev/null || true) -# 按 ContainerGroupName 匹配,提取对应的 ContainerGroupId -EXISTING_IDS=$(echo "$ALL_GROUPS" | python3 -c " -import sys, json -data = json.load(sys.stdin) -for g in data.get('ContainerGroups', []): - if g.get('ContainerGroupName') == '$ECI_CONTAINER_GROUP_NAME': - print(g['ContainerGroupId']) -" 2>/dev/null || true) +EXISTING_ID=$(echo "$EXISTING" | grep -o '"ContainerGroupId":"[^"]*"' | cut -d'"' -f4 || true) -if [ -n "$EXISTING_IDS" ]; then - for EXISTING_ID in $EXISTING_IDS; do - echo "🗑️ 删除旧容器组: $EXISTING_ID" - aliyun eci DeleteContainerGroup \ +if [ -n "$EXISTING_ID" ]; then + echo "🗑️ 删除旧容器组: $EXISTING_ID" + aliyun eci DeleteContainerGroup \ + --RegionId "$ECI_REGION" \ + --ContainerGroupId "$EXISTING_ID" + + echo "⏳ 等待容器组删除..." + for i in {1..60}; do + STATUS=$(aliyun eci DescribeContainerGroups \ --RegionId "$ECI_REGION" \ - --ContainerGroupId "$EXISTING_ID" - - echo "⏳ 等待容器组删除..." - for i in {1..60}; do - STATUS=$(aliyun eci DescribeContainerGroups \ - --RegionId "$ECI_REGION" \ - --ContainerGroupId "$EXISTING_ID" 2>/dev/null | grep -o '"Status":"[^"]*"' | cut -d'"' -f4 || echo "DELETED") - if [ "$STATUS" = "DELETED" ]; then - echo "✅ 容器组已删除" - break - fi - sleep 2 - done + --ContainerGroupId "$EXISTING_ID" 2>/dev/null | grep -o '"Status":"[^"]*"' | cut -d'"' -f4 || echo "DELETED") + if [ "$STATUS" = "DELETED" ]; then + break + fi + sleep 2 done -else - echo "✅ 无同名容器组,直接创建" fi -# 环境变量(CI 环境无 .env,密码从 eci.conf 或使用默认值) -POSTGRES_PASSWORD="${ECI_POSTGRES_PASSWORD:-postgres}" +# 从 .env 加载环境变量传递给容器 +ENV_FILE="$SCRIPT_DIR/../.env" + +# PostgreSQL 密码 +POSTGRES_PASSWORD=$(grep "^POSTGRES_PASSWORD=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2 || echo "postgres") + +# 构建 DATABASE_URL (连接到同容器组的 PostgreSQL) DATABASE_URL="postgresql://postgres:$POSTGRES_PASSWORD@127.0.0.1:5432/map_stores?schema=public&connection_limit=20&pool_timeout=60" -echo "🐘 创建新容器组(应用 + PostgreSQL 两个容器)..." -# Container 1: Next.js 应用 (从私有 Registry 内网拉取,速度快) -# Container 2: PostgreSQL (从私有 Registry 内网拉取,预热镜像) -set +e +echo "🐘 创建新容器组..." RESULT=$(aliyun eci CreateContainerGroup \ --RegionId "$ECI_REGION" \ --SecurityGroupId "$ECI_SECURITY_GROUP_ID" \ @@ -86,26 +75,40 @@ RESULT=$(aliyun eci CreateContainerGroup \ --RestartPolicy Always \ --EipInstanceId "$ECI_EIP_ID" \ --PlainHttpRegistry "$ECI_REGISTRY_INTRANET_URL" \ + `# NAS 数据卷` \ --Volume.1.Name nas-data \ --Volume.1.Type NFSVolume \ --Volume.1.NFSVolume.Server "$ECI_NAS_SERVER" \ - --Volume.1.NFSVolume.Path "/" \ - --Volume.2.Name nas-pgdata \ - --Volume.2.Type NFSVolume \ - --Volume.2.NFSVolume.Server "$ECI_NAS_SERVER" \ - --Volume.2.NFSVolume.Path "/postgres-data" \ - --Container.1.Name app \ - --Container.1.Image "$ECI_REGISTRY_INTRANET_URL/d8d-user-release:${1:-latest}" \ + --Volume.1.NFSVolume.Path / \ + `# Container 1: Next.js 应用(从私有 Registry 内网拉取,速度快)` \ + --Container.1.Name map-collector \ + --Container.1.Image "$ECI_REGISTRY_INTRANET_URL/d8d-user-release:$IMAGE_TAG" \ --Container.1.Cpu 2 \ --Container.1.Memory 2 \ --Container.1.Port.1.Protocol TCP \ --Container.1.Port.1.Port 3001 \ + --Container.1.ImagePullPolicy Always \ + --Container.1.EnvironmentVarHide true \ --Container.1.EnvironmentVar.1.Key DATABASE_URL \ --Container.1.EnvironmentVar.1.Value "$DATABASE_URL" \ + `# 从 .env 加载 API keys` \ --Container.1.EnvironmentVar.2.Key ANTHROPIC_API_KEY \ - --Container.1.EnvironmentVar.2.Value "${ECI_ANTHROPIC_API_KEY:-}" \ - --Container.1.EnvironmentVar.3.Key AMAP_API_KEY \ - --Container.1.EnvironmentVar.3.Value "${ECI_AMAP_API_KEY:-}" \ + --Container.1.EnvironmentVar.2.Value "$(grep "^ANTHROPIC_API_KEY=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2 || echo "")" \ + --Container.1.EnvironmentVar.3.Key ANTHROPIC_BASE_URL \ + --Container.1.EnvironmentVar.3.Value "$(grep "^ANTHROPIC_BASE_URL=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2 || echo "")" \ + --Container.1.EnvironmentVar.4.Key ANTHROPIC_MODEL \ + --Container.1.EnvironmentVar.4.Value "$(grep "^ANTHROPIC_MODEL=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2 || echo "")" \ + --Container.1.EnvironmentVar.5.Key AMAP_API_KEY \ + --Container.1.EnvironmentVar.5.Value "$(grep "^AMAP_API_KEY=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2 || echo "")" \ + --Container.1.EnvironmentVar.6.Key BAIDU_API_KEY \ + --Container.1.EnvironmentVar.6.Value "$(grep "^BAIDU_API_KEY=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2 || echo "")" \ + --Container.1.EnvironmentVar.7.Key TENCENT_API_KEY \ + --Container.1.EnvironmentVar.7.Value "$(grep "^TENCENT_API_KEY=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2 || echo "")" \ + --Container.1.EnvironmentVar.8.Key NEXT_PUBLIC_AMAP_JS_KEY \ + --Container.1.EnvironmentVar.8.Value "$(grep "^NEXT_PUBLIC_AMAP_JS_KEY=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2 || echo "")" \ + --Container.1.EnvironmentVar.9.Key NEXT_PUBLIC_AMAP_JS_SECRET \ + --Container.1.EnvironmentVar.9.Value "$(grep "^NEXT_PUBLIC_AMAP_JS_SECRET=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2 || echo "")" \ + `# Container 2: PostgreSQL sidecar(从内网 Registry 拉取预热镜像)` \ --Container.2.Name postgres \ --Container.2.Image "$ECI_REGISTRY_INTRANET_URL/postgres:17-alpine" \ --Container.2.Cpu 0.5 \ @@ -118,29 +121,20 @@ RESULT=$(aliyun eci CreateContainerGroup \ --Container.2.EnvironmentVar.2.Value postgres \ --Container.2.EnvironmentVar.3.Key POSTGRES_PASSWORD \ --Container.2.EnvironmentVar.3.Value "$POSTGRES_PASSWORD" \ - --Container.2.VolumeMount.1.Name nas-pgdata \ - --Container.2.VolumeMount.1.MountPath "/var/lib/postgresql/data" \ - --Container.2.VolumeMount.1.ReadOnly false 2>&1) -CREATE_EXIT=$? -set -e + --Container.2.VolumeMount.1.Name nas-data \ + --Container.2.VolumeMount.1.MountPath /var/lib/postgresql/data \ + --Container.2.VolumeMount.1.ReadOnly false) -if [ $CREATE_EXIT -ne 0 ]; then - echo "❌ CreateContainerGroup 失败 (exit=$CREATE_EXIT)" - echo "$RESULT" - exit 1 -fi - -CONTAINER_GROUP_ID=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['ContainerGroupId'])" 2>/dev/null) +CONTAINER_GROUP_ID=$(echo "$RESULT" | grep -o '"ContainerGroupId":"[^"]*"' | cut -d'"' -f4) echo "✅ 容器组创建成功: $CONTAINER_GROUP_ID" -echo "⏳ 等待容器组启动(内网拉取镜像,速度快,预计 30 秒-2 分钟)..." -for i in {1..60}; do +echo "⏳ 等待容器组启动..." +for i in {1..120}; do STATUS=$(aliyun eci DescribeContainerGroups \ --RegionId "$ECI_REGION" \ - --ContainerGroupIds "[\"$CONTAINER_GROUP_ID\"]" 2>/dev/null | python3 -c "import sys,json; data=json.load(sys.stdin); print(data['ContainerGroups'][0]['Status'])" 2>/dev/null || echo "Pending") + --ContainerGroupId "$CONTAINER_GROUP_ID" 2>/dev/null | grep -o '"Status":"[^"]*"' | cut -d'"' -f4 || echo "Pending") if [ "$STATUS" = "Running" ]; then - echo "✅ 容器组运行中" break fi @@ -149,112 +143,8 @@ for i in {1..60}; do exit 1 fi - echo " 状态: $STATUS (等待 $((i*5)) 秒)" - sleep 5 -done - -# === 部署后验证 === - -# 层 1: 容器级状态检查(Ready + RestartCount) -echo "🔍 检查容器就绪状态..." -sleep 3 -CONTAINER_DETAIL=$(aliyun eci DescribeContainerGroups \ - --RegionId "$ECI_REGION" \ - --ContainerGroupIds "[\"$CONTAINER_GROUP_ID\"]" 2>/dev/null) - -NOT_READY=$(echo "$CONTAINER_DETAIL" | python3 -c " -import sys, json -data = json.load(sys.stdin) -for c in data['ContainerGroups'][0].get('Containers', []): - if not c.get('Ready', False): - print(f' ❌ {c[\"Name\"]} Ready=False') - rc = c.get('RestartCount', 0) - if rc > 0: - print(f' ⚠️ {c[\"Name\"]} RestartCount={rc}') -" 2>/dev/null) - -if [ -n "$NOT_READY" ]; then - echo "$NOT_READY" - echo "⚠️ 有容器未就绪或正在重启,继续等待..." - # 再等一轮 - for i in {1..12}; do - sleep 5 - CONTAINER_DETAIL=$(aliyun eci DescribeContainerGroups \ - --RegionId "$ECI_REGION" \ - --ContainerGroupIds "[\"$CONTAINER_GROUP_ID\"]" 2>/dev/null) - ALL_READY=$(echo "$CONTAINER_DETAIL" | python3 -c " -import sys, json -data = json.load(sys.stdin) -ok = True -for c in data['ContainerGroups'][0].get('Containers', []): - if not c.get('Ready', False): - ok = False - if c.get('RestartCount', 0) > 0: - ok = False -print('yes' if ok else 'no') -" 2>/dev/null) - if [ "$ALL_READY" = "yes" ]; then - echo "✅ 所有容器就绪" - break - fi - done -else - echo "✅ 所有容器就绪 (Ready=True, RestartCount=0)" -fi - -# 层 2: 等待 PostgreSQL 就绪 + Schema 迁移 -echo "⏳ 等待 PostgreSQL 就绪..." -for i in {1..30}; do - PG_READY=$(aliyun eci ExecContainerCommand \ - --RegionId "$ECI_REGION" \ - --ContainerGroupId "$CONTAINER_GROUP_ID" \ - --ContainerName postgres \ - --Command '["/bin/sh", "-c", "pg_isready -h 127.0.0.1 -p 5432"]' 2>/dev/null || echo "not_ready") - if echo "$PG_READY" | grep -q "accepting connections"; then - echo "✅ PostgreSQL 就绪" - break - fi - sleep 2 -done - -echo "📦 执行 Prisma Schema 迁移..." -set +e -MIGRATE_RESULT=$(aliyun eci ExecContainerCommand \ - --RegionId "$ECI_REGION" \ - --ContainerGroupId "$CONTAINER_GROUP_ID" \ - --ContainerName app \ - --Command '["/bin/sh", "-c", "cd /app && npx prisma db push --skip-generate 2>&1"]' 2>&1) -MIGRATE_EXIT=$? -set -e - -if [ $MIGRATE_EXIT -ne 0 ]; then - echo "⚠️ Schema 迁移执行异常 (exit=$MIGRATE_EXIT)" - echo "$MIGRATE_RESULT" -else - echo "✅ Schema 迁移完成" -fi - -# 层 3: HTTP 健康检查 -echo "🔍 验证应用健康..." -for i in {1..30}; do - HEALTH=$(curl -s -o /dev/null -w "%{http_code}" "http://$ECI_EIP_ADDRESS:3001/api/health" 2>/dev/null || echo "000") - if [ "$HEALTH" = "200" ]; then - echo "✅ 应用健康检查通过" - break - fi - if [ "$i" = "30" ]; then - echo "⚠️ 应用健康检查超时 (HTTP $HEALTH)" - echo "📋 容器事件:" - echo "$CONTAINER_DETAIL" | python3 -c " -import sys, json -data = json.load(sys.stdin) -for e in data['ContainerGroups'][0].get('Events', [])[-5:]: - print(f' [{e.get(\"Type\")}] {e.get(\"Message\")}') -" 2>/dev/null - else - echo " 健康检查: HTTP $HEALTH (等待 $((i*5)) 秒)" - sleep 5 - fi + echo " 状态: $STATUS" + sleep 3 done echo "" diff --git a/.claude/skills/d8d-release/assets/eci-registry.sh b/.claude/skills/d8d-release/assets/eci-registry.sh index a08d28d..7ee27d9 100755 --- a/.claude/skills/d8d-release/assets/eci-registry.sh +++ b/.claude/skills/d8d-release/assets/eci-registry.sh @@ -8,6 +8,10 @@ set -e source "$(dirname "$0")/../eci.conf" REGION=${ECI_REGION:-cn-beijing} + +# 资源组参数(RAM 子帐号绑定时必填) +RG_PARAM="" +[ -n "$ECI_RESOURCE_GROUP_ID" ] && RG_PARAM="--ResourceGroupId $ECI_RESOURCE_GROUP_ID" CONTAINER_GROUP_NAME="d8d-eci-registry" CPU=0.5 MEMORY=1.0 @@ -22,21 +26,24 @@ echo "" # 检查容器组是否已存在 echo "检查容器组是否存在..." EXISTING=$(aliyun eci DescribeContainerGroups --region "$REGION" \ + $RG_PARAM \ --ContainerGroupName "$CONTAINER_GROUP_NAME" 2>/dev/null \ | grep -c '"ContainerGroupId"' || true) if [ "$EXISTING" -gt 0 ]; then echo "容器组已存在,正在删除..." CONTAINER_GROUP_ID=$(aliyun eci DescribeContainerGroups --region "$REGION" \ + $RG_PARAM \ --ContainerGroupName "$CONTAINER_GROUP_NAME" 2>/dev/null \ | grep -o '"ContainerGroupId":"[^"]*"' | cut -d'"' -f4) - aliyun eci DeleteContainerGroup --region "$REGION" --ContainerGroupId "$CONTAINER_GROUP_ID" >/dev/null + aliyun eci DeleteContainerGroup --region "$REGION" $RG_PARAM --ContainerGroupId "$CONTAINER_GROUP_ID" >/dev/null # 等待删除完成 echo "等待容器组删除..." for i in {1..30}; do STATUS=$(aliyun eci DescribeContainerGroups --region "$REGION" \ + $RG_PARAM \ --ContainerGroupName "$CONTAINER_GROUP_NAME" 2>/dev/null \ | grep -o '"Status":"[^"]*"' | cut -d'"' -f4 || true) if [ -z "$STATUS" ]; then @@ -50,6 +57,7 @@ fi # 创建新容器组 echo "创建 Registry 容器组(Spot 实例)..." RESULT=$(aliyun eci CreateContainerGroup --region "$REGION" \ + $RG_PARAM \ --ContainerGroupName "$CONTAINER_GROUP_NAME" \ --VSwitchId "$ECI_VSWITCH_ID" \ --SecurityGroupId "$ECI_SECURITY_GROUP_ID" \ @@ -88,6 +96,7 @@ echo "等待容器组启动..." # 等待容器组 Running for i in {1..60}; do STATUS=$(aliyun eci DescribeContainerGroups --region "$REGION" \ + $RG_PARAM \ --ContainerGroupId "$CONTAINER_GROUP_ID" 2>/dev/null \ | grep -o '"Status":"[^"]*"' | cut -d'"' -f4) @@ -106,6 +115,7 @@ echo "" echo "获取容器组内网 IP..." for i in {1..30}; do INTRANET_IP=$(aliyun eci DescribeContainerGroups --region "$REGION" \ + $RG_PARAM \ --ContainerGroupId "$CONTAINER_GROUP_ID" 2>/dev/null \ | grep -o '"IntranetIp":"[^"]*"' | cut -d'"' -f4) diff --git a/.claude/skills/d8d-release/assets/eci.conf.template b/.claude/skills/d8d-release/assets/eci.conf.template index 45c2c4a..8765f25 100644 --- a/.claude/skills/d8d-release/assets/eci.conf.template +++ b/.claude/skills/d8d-release/assets/eci.conf.template @@ -14,13 +14,23 @@ ECI_NAS_SERVER= ECI_EIP_ID= ECI_EIP_ADDRESS= -# 私有 Registry EIP 和地址(ECI Spot 实例) +# 镜像仓库模式: acr(阿里云 ACR)| eci-registry(自建 ECI Registry) +# acr: 帐号已开通 ACR 时使用,稳定可靠 +# eci-registry: 无 ACR 时使用,Spot 实例自建私有 Registry,成本更低 +REGISTRY_MODE=eci-registry + +# ACR 配置(REGISTRY_MODE=acr 时使用) +ACR_REGISTRY=registry-vpc.cn-beijing.aliyuncs.com +ACR_NAMESPACE=d8dcloud +ACR_IMAGE_NAME= + +# ECI Registry 配置(REGISTRY_MODE=eci-registry 时使用) ECI_REGISTRY_EIP_ID= ECI_REGISTRY_EIP_ADDRESS= ECI_REGISTRY_PORT=5000 ECI_REGISTRY_URL= -# 私有 Registry 内网 IP(用于应用 ECI 内网拉取镜像,由 eci-registry.sh 自动填充) +# ECI Registry 内网 IP(由 eci-registry.sh 自动填充) ECI_REGISTRY_INTRANET_IP= ECI_REGISTRY_INTRANET_URL= @@ -28,6 +38,9 @@ ECI_REGISTRY_INTRANET_URL= ALI_ACCESS_KEY_ID= ALI_ACCESS_KEY_SECRET= +# 资源组 ID(RAM 子帐号绑定资源组时必填,为空则忽略) +ECI_RESOURCE_GROUP_ID= + # Gitea 配置(用于 Actions 日志查询) GITEA_URL=https://gitea.d8d.fun GITEA_USERNAME=d8dfun @@ -39,3 +52,14 @@ GITEA_REPO_NAME= ECI_CONTAINER_GROUP_NAME=map-collector ECI_CPU=2.5 ECI_MEMORY=3.5 + +# NPM 管理员配置 +NPM_ADMIN_EMAIL= +NPM_ADMIN_PASSWORD= + +# DNS 配置 +DNS_DOMAIN=d8d.fun +DNS_DOMAINS= + +# NPM 代理配置(格式: 域名:端口:WebSocket) +NPM_PROXY_CONFIG= diff --git a/.claude/skills/d8d-release/assets/release.yaml b/.claude/skills/d8d-release/assets/release.yaml index b818d4c..543facf 100644 --- a/.claude/skills/d8d-release/assets/release.yaml +++ b/.claude/skills/d8d-release/assets/release.yaml @@ -35,13 +35,13 @@ jobs: REPO_NAME=$(echo "${{ gitea.repository }}" | sed 's|/|-|g') echo "REPO_NAME=$REPO_NAME" >> $GITHUB_ENV - # 从 eci.conf 读取私有 Registry 地址 + # 从 eci.conf 加载 Registry 地址 source eci.conf echo "REGISTRY_URL=$ECI_REGISTRY_URL" >> $GITHUB_ENV echo "提取的版本号:$VERSION" echo "处理后的仓库名:$REPO_NAME" - echo "私有 Registry 地址:$ECI_REGISTRY_URL" + echo "Registry 地址:$ECI_REGISTRY_URL" - name: 设置 Docker Buildx(支持 HTTP 私有 Registry) uses: docker/setup-buildx-action@v3 @@ -54,12 +54,6 @@ jobs: http = true insecure = true - - name: 登录私有 Registry(从 eci.conf 读取) - run: | - source eci.conf - # 私有 Registry 使用 HTTP,无需认证 - echo "使用私有 Registry: $ECI_REGISTRY_URL" - - name: 构建并推送应用镜像到私有 Registry uses: docker/build-push-action@v5 with: @@ -72,16 +66,12 @@ jobs: - name: 预热 postgres:17-alpine 镜像到私有 Registry run: | source eci.conf - # 检查镜像是否已存在 - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://$ECI_REGISTRY_URL/v2/postgres/tags/list") - if [ "$HTTP_CODE" = "200" ]; then - echo "⏭️ postgres:17-alpine 已存在于私有 Registry,跳过预热" - exit 0 - fi - # 通过 buildx 推送,复用步骤10已配置的 insecure registry - TMPDIR=$(mktemp -d) - printf 'FROM postgres:17-alpine\n' > $TMPDIR/Dockerfile - docker buildx build --push -t $ECI_REGISTRY_URL/postgres:17-alpine $TMPDIR + # 从 Docker Hub 拉取官方 postgres:17-alpine + docker pull postgres:17-alpine + # 重命名为私有 Registry 地址 + docker tag postgres:17-alpine $ECI_REGISTRY_URL/postgres:17-alpine + # 推送到私有 Registry + docker push $ECI_REGISTRY_URL/postgres:17-alpine echo "postgres:17-alpine 镜像预热完成" - name: 安装 aliyun CLI(用于 ECI 部署) @@ -101,19 +91,3 @@ jobs: chmod +x scripts/eci-deploy.sh IMAGE_TAG="${{ env.REPO_NAME }}-${{ env.VERSION }}" ./scripts/eci-deploy.sh "$IMAGE_TAG" - - - name: 验证应用健康 - run: | - source eci.conf - echo "🔍 CI 最终健康检查..." - for i in {1..30}; do - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://$ECI_EIP_ADDRESS:3001/api/health" 2>/dev/null || echo "000") - if [ "$HTTP_CODE" = "200" ]; then - echo "✅ 应用健康检查通过 (HTTP 200)" - exit 0 - fi - echo " 等待中... HTTP $HTTP_CODE ($((i*5))秒)" - sleep 5 - done - echo "❌ 健康检查超时,应用可能未正常启动" - exit 1 diff --git a/.claude/skills/d8d-release/modes/deployment-mode.md b/.claude/skills/d8d-release/modes/deployment-mode.md index 226af25..294e755 100644 --- a/.claude/skills/d8d-release/modes/deployment-mode.md +++ b/.claude/skills/d8d-release/modes/deployment-mode.md @@ -19,6 +19,7 @@ | Phase 03 | [phases/phase-03-ci-setup.md](../phases/phase-03-ci-setup.md) | CI 工作流部署 | | Phase 04 | [phases/phase-04-deploy.md](../phases/phase-04-deploy.md) | 触发发布、ECI 容器组部署 | | Phase 05 | [phases/phase-05-verify.md](../phases/phase-05-verify.md) | 部署验证、健康检查 | +| Phase 06 | [phases/phase-06-dns-npm-ssl.md](../phases/phase-06-dns-npm-ssl.md) | DNS + NPM 代理 + SSL 证书 | --- @@ -35,6 +36,8 @@ | postgres 预热 | ✅ | ✅ | | ECI 容器组重建 | ✅ | ✅ | | 健康检查 | ✅ | ✅ | +| DNS 配置 | ✅ | ⏭️ 跳过(已存在) | +| NPM 代理 + SSL | ✅ | ⏭️ 复用已有(自动续期) | --- @@ -42,9 +45,19 @@ 在标准部署模式中,以下优化自动生效: -### 1. 内网镜像拉取 +### 0. 镜像仓库模式选择 -**原理**: +根据 `eci.conf` 中的 `REGISTRY_MODE` 选择镜像仓库方案: + +| 模式 | 值 | 适用场景 | 基础设施 | +|------|-----|----------|----------| +| ACR | `acr` | 帐号已开通 ACR(个人版/企业版) | 无需额外 ECI | +| 自建 Registry | `eci-registry` | 无 ACR,或需要完全自托管 | 需创建 Registry ECI + EIP | + +**ACR 模式优势**:稳定可靠,无需维护 Registry 容器,ACR 个人版免费 +**自建 Registry 优势**:完全自托管,Spot 实例成本极低,不依赖外部服务 + +### 1. 内网镜像拉取 - 应用 ECI 和 Registry ECI 在同一 VPC 内 - 应用通过内网 IP 拉取镜像,不走公网 @@ -97,8 +110,10 @@ | 操作 | 命令 | |------|------| -| 发布新版本 | `./scripts/release_tag.sh x.y.z` | -| 查看已有 tag | `./scripts/release_tag.sh -l` | +| 发布新版本 | `python3 scripts/ci.py build x.y.z` 或 `./scripts/release_tag.sh x.y.z` | +| 查看构建状态 | `python3 scripts/ci.py status` | +| 查看构建日志 | `python3 scripts/ci.py log ` | +| 查看已有 tag | `python3 scripts/ci.py tags` 或 `./scripts/release_tag.sh -l` | | 查看 Registry 镜像 | `curl http://{registry-ip}:5000/v2/_catalog` | | 数据同步 | 调用 `/d8d-release` 选择「数据同步模式」 | diff --git a/.claude/skills/d8d-release/phases/phase-01-infra.md b/.claude/skills/d8d-release/phases/phase-01-infra.md index dfbdab2..ff6dfaf 100644 --- a/.claude/skills/d8d-release/phases/phase-01-infra.md +++ b/.claude/skills/d8d-release/phases/phase-01-infra.md @@ -2,6 +2,18 @@ 按名称标记 `d8d-eci-*`,**不存在则创建,存在则复用**。所有资源复用同一账号下的项目。 +## 前置:资源组参数 + +当 RAM 子帐号绑定资源组时,所有资源创建和查询 API 必须传 `--ResourceGroupId`。在执行前初始化: + +```bash +source eci.conf +RG_PARAM="" +[ -n "$ECI_RESOURCE_GROUP_ID" ] && RG_PARAM="--ResourceGroupId $ECI_RESOURCE_GROUP_ID" +``` + +> 后续所有 `aliyun` 命令中 `$RG_PARAM` 均指此变量。为空时不传,兼容无资源组的 RAM 帐号。 + ## 资源清单 | 序号 | 资源 | 名称标记 | 关键配置 | @@ -20,7 +32,7 @@ **查询是否存在**: ```bash -aliyun vpc DescribeVpcs --VpcName d8d-eci-vpc +aliyun vpc DescribeVpcs $RG_PARAM --VpcName d8d-eci-vpc ``` **如不存在则创建**: @@ -28,7 +40,8 @@ aliyun vpc DescribeVpcs --VpcName d8d-eci-vpc aliyun vpc CreateVpc \ --RegionId cn-beijing \ --VpcName d8d-eci-vpc \ - --CidrBlock 192.168.0.0/16 + --CidrBlock 192.168.0.0/16 \ + $RG_PARAM ``` **记录**:`ECI_VPC_ID` → eci.conf @@ -51,6 +64,8 @@ aliyun vpc CreateVSwitch \ --CidrBlock 192.168.1.0/24 ``` +> 注意:`CreateVSwitch` 不支持 `--ResourceGroupId` 参数,资源继承自 VPC 的资源组。 + **记录**:`ECI_VSWITCH_ID` → eci.conf --- @@ -62,7 +77,8 @@ aliyun ecs CreateSecurityGroup \ --RegionId cn-beijing \ --VpcId $ECI_VPC_ID \ --SecurityGroupName d8d-eci-sg \ - --Description "ECI deployment security group" + --Description "ECI deployment security group" \ + $RG_PARAM ``` **添加入站规则**: @@ -94,7 +110,8 @@ aliyun nas CreateFileSystem \ --RegionId cn-beijing \ --ProtocolType NFS \ --StorageType Performance \ - --Description d8d-eci-nas + --Description d8d-eci-nas \ + $RG_PARAM ``` **创建挂载点**: @@ -114,6 +131,24 @@ ECI_NAS_SERVER=${MountTargetDomain} → eci.conf +**创建挂载目录**(ECI NFS 挂载子路径时,目录必须存在): + +```bash +NAS_FS_ID=$(echo "$ECI_NAS_SERVER" | cut -d'-' -f1) + +# 根据实际挂载路径创建目录,以下为示例 +for dir in /d8d-next /d8d-next/pgdata /d8d-next/minio-data \ + /d8d-next/npm /d8d-next/npm/data /d8d-next/npm/letsencrypt; do + aliyun nas CreateDir \ + --FileSystemId "$NAS_FS_ID" \ + --RootDirectory "$dir" \ + --OwnerUserId 0 --OwnerGroupId 0 --Permission 0755 \ + --RegionId "$ECI_REGION" +done +``` + +> **铁律**:ECI NFS 挂载指定子路径时,该路径必须在 NAS 上存在,否则 `MountVolume.SetUp` 失败。如果目录已存在,`CreateDir` 不会报错。 + --- ## 5. 应用 EIP 创建 diff --git a/.claude/skills/d8d-release/phases/phase-04-deploy.md b/.claude/skills/d8d-release/phases/phase-04-deploy.md index ba1b3cd..3f48837 100644 --- a/.claude/skills/d8d-release/phases/phase-04-deploy.md +++ b/.claude/skills/d8d-release/phases/phase-04-deploy.md @@ -17,14 +17,45 @@ --- -## 2. CI 构建流程 +## 2. 监控 CI 构建 + +Tag 推送后,使用 **CronCreate 定时任务** 监控 CI 构建状态(不要用 `sleep`): + +``` +CronCreate: 每 2 分钟检查一次(一次性) +cron: "*/2 * * * *", recurring: false +prompt: "检查 CI 构建 () 状态:python3 scripts/ci.py status。 + success → 部署并进入 Phase 05;failure → 查看日志报告错误;running → 报告状态继续等待。" +``` + +或手动查询: + +```bash +python3 scripts/ci.py status +``` + +**等待策略**: +- **running** → 用 CronCreate 设置 2 分钟后再次检查(Docker 构建通常 5-10 分钟) +- **success** → CI 构建成功,ECI 部署由 CI 自动完成,进入 Phase 05 验证 +- **failure** → 查看构建日志排查: + +```bash +python3 scripts/ci.py log +``` + +> **关键**:必须等待 CI 构建成功后才能进入 Phase 05。如果 CI 失败,修复问题后重新推送 tag。 +> **禁止使用 `sleep`**:用 CronCreate 定时任务代替 bash sleep 来轮询 CI 状态。 + +--- + +## 3. CI 构建流程(参考) Gitea Actions 自动执行以下步骤: ### 阶段 A: 构建应用镜像 1. **检出代码**:actions/checkout@v4 -2. **设置 Docker Buildx**:docker/setup-buildx-action@v3(配置 insecure registry 支持 HTTP) +2. **设置 Docker Buildx**:docker/setup-buildx-action@v3 3. **提取版本信息**:解析 tag 名称、仓库名称 4. **构建并推送应用镜像**: - Docker 多阶段构建(standalone Next.js) @@ -32,21 +63,14 @@ Gitea Actions 自动执行以下步骤: ### 阶段 B: postgres 镜像预热 -**先检查是否已存在**(避免重复推送): +**从 Docker Hub 拉取官方镜像**: ```bash -HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://$ECI_REGISTRY_URL/v2/postgres/tags/list") -if [ "$HTTP_CODE" = "200" ]; then - echo "⏭️ postgres:17-alpine 已存在,跳过" - exit 0 -fi +docker pull postgres:17-alpine +docker tag postgres:17-alpine $ECI_REGISTRY_URL/postgres:17-alpine +docker push $ECI_REGISTRY_URL/postgres:17-alpine ``` -不存在时通过 buildx 推送(支持 HTTP insecure registry): -```bash -TMPDIR=$(mktemp -d) -printf 'FROM postgres:17-alpine\n' > $TMPDIR/Dockerfile -docker buildx build --push -t $ECI_REGISTRY_URL/postgres:17-alpine $TMPDIR -``` +> **优化效果**:首次拉取慢(约 1 分钟),但 ECI 部署时内网拉取 < 1 秒。 --- @@ -54,68 +78,36 @@ docker buildx build --push -t $ECI_REGISTRY_URL/postgres:17-alpine $TMPDIR CI 自动调用 `scripts/eci-deploy.sh`: -### 前置准备:NAS 目录预创建 - -**确保 postgres 数据目录存在且权限正确**(UID 70 = postgres 用户): - -```bash -NAS_FS_ID=$(echo "$ECI_NAS_SERVER" | cut -d'.' -f1) -aliyun nas CreateDir \ - --FileSystemId "$NAS_FS_ID" \ - --RootDirectory "/postgres-data" \ - --OwnerUserId 70 \ - --OwnerGroupId 70 \ - --Permission "0755" \ - --RegionId "$ECI_REGION" -``` - -> **为什么需要预创建**:postgres 容器以 UID 70 运行,NAS 默认目录 owner 为 root。 -> 不预创建会导致 `mkdir: Permission denied`,postgres CrashLoopBackOff。 - ### 删除旧容器组(如存在) -**按名称匹配查找**(`DescribeContainerGroups` 不支持名称过滤,需 python3 过滤): - ```bash -ALL_GROUPS=$(aliyun eci DescribeContainerGroups --RegionId "$ECI_REGION") -EXISTING_IDS=$(echo "$ALL_GROUPS" | python3 -c " -import sys, json -data = json.load(sys.stdin) -for g in data.get('ContainerGroups', []): - if g.get('ContainerGroupName') == '$ECI_CONTAINER_GROUP_NAME': - print(g['ContainerGroupId']) -") +aliyun eci DeleteContainerGroup \ + --RegionId "$ECI_REGION" \ + --ContainerGroupId "$EXISTING_ID" ``` 轮询等待删除完成。 ### 创建新容器组 -**错误捕获**:使用 `set +e` 包裹,捕获完整错误信息: - ```bash -set +e -RESULT=$(aliyun eci CreateContainerGroup ... 2>&1) -CREATE_EXIT=$? -set -e - -if [ $CREATE_EXIT -ne 0 ]; then - echo "❌ CreateContainerGroup 失败 (exit=$CREATE_EXIT)" - echo "$RESULT" - exit 1 -fi +aliyun eci CreateContainerGroup \ + --RegionId "$ECI_REGION" \ + --SecurityGroupId "$ECI_SECURITY_GROUP_ID" \ + --VSwitchId "$ECI_VSWITCH_ID" \ + --ContainerGroupName "$ECI_CONTAINER_GROUP_NAME" \ + --Cpu "$ECI_CPU" \ + --Memory "$ECI_MEMORY" \ + --RestartPolicy Always \ + --EipInstanceId "$ECI_EIP_ID" \ + --PlainHttpRegistry "$ECI_REGISTRY_INTRANET_URL" \ + --Volume.1.Name nas-data \ + --Volume.1.Type NFSVolume \ + --Volume.1.NFSVolume.Server "$ECI_NAS_SERVER" \ + --Volume.1.NFSVolume.Path "/" \ + ... # 两个容器配置 ``` -**两个 NAS 卷挂载**: - -| 卷名 | NAS 路径 | 用途 | 挂载到 | -|------|---------|------|--------| -| nas-data | / | 通用存储 | - | -| nas-pgdata | /postgres-data | PostgreSQL 数据(权限 UID 70) | postgres: /var/lib/postgresql/data | - -> **为什么独立卷**:postgres 需要目录 owner 为 UID 70。NAS 根目录 owner 为 root 不可改, -> 独立卷通过 `CreateDir` 指定 ownerUid=70 解决权限问题。 - #### Container 1: Next.js 应用 | 配置项 | 值 | @@ -135,7 +127,7 @@ fi | Memory | 1.5 GiB | | Port | TCP 5432 | | 环境变量 | POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD | -| Volume Mount | nas-pgdata → /var/lib/postgresql/data | +| Volume Mount | NAS 挂载到 `/var/lib/postgresql/data` | **总计**:2.5 vCPU + 3.5 GiB 内存 @@ -144,42 +136,12 @@ fi **内网拉取优化后预估时间**:30 秒 - 2 分钟 ```bash -STATUS=$(aliyun eci DescribeContainerGroups --ContainerGroupIds ... | \ - python3 -c "import sys,json; data=json.load(sys.stdin); print(data['ContainerGroups'][0]['Status'])") +# 轮询查询状态 +while true; do + STATUS=$(aliyun eci DescribeContainerGroups --ContainerGroupId $ID | grep Status) + if [ "$STATUS" = "Running" ]; then break; fi + sleep 5 +done ``` -### 部署后验证(四层检查) - -容器组 Running 后自动执行四层检查: - -**层 1: 容器级状态**(Ready + RestartCount) -```bash -CONTAINER_DETAIL=$(aliyun eci DescribeContainerGroups --ContainerGroupIds ... ) -# 检查每个容器的 Ready=True 和 RestartCount=0 -``` - -**层 2: PostgreSQL 就绪** -```bash -aliyun eci ExecContainerCommand --ContainerName postgres \ - --Command '["/bin/sh", "-c", "pg_isready -h 127.0.0.1 -p 5432"]' -``` - -**层 3: Schema 迁移** -```bash -aliyun eci ExecContainerCommand --ContainerName app \ - --Command '["/bin/sh", "-c", "cd /app && npx prisma db push --skip-generate"]' -``` - -**层 4: HTTP 健康检查** -```bash -curl -s -o /dev/null -w "%{http_code}" "http://$ECI_EIP_ADDRESS:3001/api/health" -# 预期: HTTP 200 -``` - -健康检查超时时自动输出容器 Events 帮助诊断。 - -### CI 最终验证 - -`release.yaml` 在 deploy 步骤后追加独立的健康检查步骤,确保 CI 以应用可用为成功标准。 - → 进入 [Phase 05: 部署验证](phase-05-verify.md) diff --git a/.claude/skills/d8d-release/phases/phase-05-verify.md b/.claude/skills/d8d-release/phases/phase-05-verify.md index ef7fa01..94aba5f 100644 --- a/.claude/skills/d8d-release/phases/phase-05-verify.md +++ b/.claude/skills/d8d-release/phases/phase-05-verify.md @@ -2,38 +2,51 @@ 验证 ECI 容器组成功启动,应用可访问。 +## 0. 确认 CI 构建成功 + +在验证容器状态前,先确认 CI 构建已完成: + +```bash +python3 scripts/ci.py status +``` + +如果最近的 run 状态不是 success,用 `python3 scripts/ci.py log ` 排查。CI 失败时容器组可能使用旧镜像或不存在。 + ## 1. 验证容器组状态 ```bash -aliyun eci DescribeContainerGroups --ContainerGroupName map-collector +aliyun eci DescribeContainerGroups \ + --RegionId $ECI_REGION \ + $RG_PARAM \ + --ContainerGroupName $ECI_CONTAINER_GROUP_NAME ``` **预期状态**:`Status: Running` -## 2. 容器级状态检查 +## 1.5 查看 ECI 事件(容器 Pending 时排查) -**检查每个容器的 Ready 和 RestartCount**: +如果容器组长时间 Pending,查看事件排查原因: ```bash -aliyun eci DescribeContainerGroups --ContainerGroupIds "[\"$CONTAINER_GROUP_ID\"]" +aliyun eci DescribeContainerGroupEvents \ + --RegionId $ECI_REGION \ + --ContainerGroupIds '["'$CONTAINER_GROUP_ID'"]' ``` -**预期结果**: -- `Containers[].Ready = true`(每个容器) -- `Containers[].RestartCount = 0`(无崩溃重启) +重点关注 `FailedMount`(NAS 目录不存在)、`Failed`(镜像拉取失败)等事件。 + +## 1.6 查看容器日志(Running 但不健康时排查) -**RestartCount > 0** 说明容器曾崩溃,检查 Events 和日志: ```bash -# 查看 Events -aliyun eci DescribeContainerGroups ... | python3 -c " -import sys, json -data = json.load(sys.stdin) -for e in data['ContainerGroups'][0].get('Events', [])[-5:]: - print(f'[{e.get(\"Type\")}] {e.get(\"Message\")}') -" +aliyun eci DescribeContainerLog \ + --RegionId $ECI_REGION \ + --ContainerGroupId $CONTAINER_GROUP_ID \ + --ContainerName d8d-next ``` -## 3. 健康检查端点 +可查看各容器的启动日志和运行时错误。 + +## 2. 健康检查端点 **调用 API**: ```bash @@ -45,7 +58,7 @@ curl http://$ECI_EIP_ADDRESS:3001/api/health { "status": "ok", "timestamp": ... } ``` -## 4. 页面访问验证 +## 3. 页面访问验证 **浏览器访问**: ``` @@ -56,36 +69,16 @@ http://$ECI_EIP_ADDRESS:3001 - 页面标题:地图门店采集平台 - 正常渲染页面内容 -## 5. 数据库表检查(首次部署必检) +## 4. PostgreSQL 连接验证 -**检查 Schema 是否完整**: - -通过 ECI exec 检查: +**容器内验证**(可选,通过 ECI exec): ```bash -aliyun eci ExecContainerCommand \ - --ContainerGroupId "$CONTAINER_GROUP_ID" \ - --ContainerName postgres \ - --Command '["/bin/sh", "-c", "psql -U postgres -d map_stores -c \"SELECT count(*) FROM information_schema.tables WHERE table_schema = \\\"public\\\";\""]' +pg_isready -h 127.0.0.1 -p 5432 -U postgres ``` -或临时开放 5432 端口检查: -```bash -PGPASSWORD=postgres psql -h $ECI_EIP_ADDRESS -U postgres -d map_stores \ - -c "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public';" -``` +或通过应用日志验证数据库连接正常。 -**预期结果**:至少 6 张表 - -| 表数 | 含义 | 操作 | -|------|------|------| -| ≥ 6 | Schema 完整 | 正常 | -| 0 | 空数据库 | → 触发数据同步(migrate 子模式) | -| 1-5 | Schema 不完整 | → 检查 prisma db push 是否执行成功 | - -**首次部署自动检测**:eci-deploy.sh 已内置 `prisma db push`,正常情况下表会自动创建。 -如表数 = 0,说明 Schema 迁移失败,需排查原因。 - -## 6. 部署报告 +## 5. 部署报告 验证完成后,输出最终部署信息: @@ -97,12 +90,11 @@ PGPASSWORD=postgres psql -h $ECI_EIP_ADDRESS -U postgres -d map_stores \ 📍 Registry 内网: {ECI_REGISTRY_INTRANET_IP}:5000 🐘 PostgreSQL: 已就绪(数据持久化在 NAS) 💾 容器规格: 2.5 vCPU + 3.5 GiB -📊 数据库: {N} 张表,{M} 条数据 💡 后续部署: 打 tag 触发 CI 即可,IP 地址永久不变 ``` -## 7. 可选:数据同步 +## 6. 可选:数据同步 如需同步本地开发数据到生产: @@ -120,5 +112,3 @@ PGPASSWORD=postgres psql -h $ECI_EIP_ADDRESS -U postgres -d map_stores \ - ✅ 两个固定 EIP,永久不变 - ✅ 内网镜像拉取,速度 < 1 秒 - ✅ NAS 持久化,容器重建不丢失数据 -- ✅ 部署后自动 Schema 迁移 + 健康检查 -- ✅ 四层验证:容器 Ready → PG 就绪 → Schema 迁移 → HTTP 健康 diff --git a/.claude/skills/d8d-release/phases/phase-06-dns-npm-ssl.md b/.claude/skills/d8d-release/phases/phase-06-dns-npm-ssl.md new file mode 100644 index 0000000..0e89356 --- /dev/null +++ b/.claude/skills/d8d-release/phases/phase-06-dns-npm-ssl.md @@ -0,0 +1,251 @@ +# Phase 06: DNS + NPM 代理 + SSL 证书配置 + +部署完成后,配置域名解析、Nginx Proxy Manager 反向代理和 SSL 证书,实现 HTTPS 域名访问。 + +## 前置条件 + +- Phase 05 验证通过,容器组 Running +- EIP 地址已分配且固定(`ECI_EIP_ADDRESS`) +- NPM 容器已启动(端口 81 可访问) +- 阿里云 AK/SK 有 Alidns 管理权限 + +--- + +## 1. DNS A 记录配置 + +使用阿里云 Alidns API 将域名指向固定 EIP。 + +### 1.1 读取配置 + +```bash +source eci.conf +# DNS_DOMAINS=www.d8d.fun,next-socket.d8d.fun,next-minio.d8d.fun +# DNS_DOMAIN=项目主域名(如 d8d.fun) +``` + +### 1.2 配置 DNS 记录 + +对每个域名,检查是否存在 A 记录,不存在则创建,存在则更新: + +```bash +for SUB in $(echo $DNS_DOMAINS | tr ',' ' '); do + RR=$(echo $SUB | sed "s/\.$DNS_DOMAIN$//" | sed "s/\.$//") + + # 查询现有记录 + RECORD=$(aliyun alidns DescribeDomainRecords \ + --DomainName "$DNS_DOMAIN" \ + --RRKeyWord "$RR" \ + --Type A) + + RECORD_ID=$(echo "$RECORD" | grep -o '"RecordId":"[^"]*"' | head -1 | cut -d'"' -f4) + + if [ -n "$RECORD_ID" ]; then + echo "更新 DNS 记录: $RR.$DNS_DOMAIN -> $ECI_EIP_ADDRESS" + aliyun alidns UpdateDomainRecord \ + --RecordId "$RECORD_ID" \ + --RR "$RR" \ + --Type A \ + --Value "$ECI_EIP_ADDRESS" + else + echo "创建 DNS 记录: $RR.$DNS_DOMAIN -> $ECI_EIP_ADDRESS" + aliyun alidns AddDomainRecord \ + --DomainName "$DNS_DOMAIN" \ + --RR "$RR" \ + --Type A \ + --Value "$ECI_EIP_ADDRESS" + fi +done +``` + +### 1.3 等待 DNS 生效 + +```bash +echo "等待 DNS 传播..." +sleep 30 +``` + +--- + +## 2. NPM 管理员初始化 + +NPM 默认帐号 `admin@example.com` / `changeme`,需改为自定义帐号。 + +### 2.1 读取配置 + +```bash +source eci.conf +# NPM_ADMIN_EMAIL=xxx@xxx.com +# NPM_ADMIN_PASSWORD=xxxxx +``` + +### 2.2 登录并修改帐号 + +```bash +EIP=$ECI_EIP_ADDRESS + +# 用默认帐号登录 +TOKEN=$(curl -s -X POST "http://$EIP:81/api/tokens" \ + -H "Content-Type: application/json" \ + -d '{"identity":"admin@example.com","secret":"changeme"}' | jq -r '.token') + +if [ "$TOKEN" = "null" ] || [ -z "$TOKEN" ]; then + echo "默认帐号已修改,使用自定义帐号登录" + TOKEN=$(curl -s -X POST "http://$EIP:81/api/tokens" \ + -H "Content-Type: application/json" \ + -d "{\"identity\":\"$NPM_ADMIN_EMAIL\",\"secret\":\"$NPM_ADMIN_PASSWORD\"}" | jq -r '.token') +else + # 修改管理员帐号 + curl -s -X PUT "http://$EIP:81/api/users/1" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$NPM_ADMIN_EMAIL\",\"name\":\"Administrator\",\"password\":\"$NPM_ADMIN_PASSWORD\",\"passwordConfirm\":\"$NPM_ADMIN_PASSWORD\"}" + echo "NPM 管理员帐号已更新" + + # 用新帐号重新登录 + TOKEN=$(curl -s -X POST "http://$EIP:81/api/tokens" \ + -H "Content-Type: application/json" \ + -d "{\"identity\":\"$NPM_ADMIN_EMAIL\",\"secret\":\"$NPM_ADMIN_PASSWORD\"}" | jq -r '.token') +fi +``` + +--- + +## 3. SSL 证书申请 + +通过 NPM API 申请 Let's Encrypt 免费 SSL 证书。 + +> **前提**: DNS A 记录已生效且指向 EIP,否则 Let's Encrypt 域名验证会失败。 + +### 3.1 为每个域名申请证书 + +```bash +for DOMAIN in $(echo $DNS_DOMAINS | tr ',' ' '); do + echo "申请 SSL 证书: $DOMAIN" + CERT_RESULT=$(curl -s -X POST "http://$EIP:81/api/nginx/certificates" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"provider\": \"letsencrypt\", + \"nice_name\": \"$DOMAIN\", + \"domain_names\": [\"$DOMAIN\"], + \"meta\": { + \"letsencrypt_email\": \"$NPM_ADMIN_EMAIL\", + \"letsencrypt_agree\": true + } + }") + + CERT_ID=$(echo "$CERT_RESULT" | jq -r '.id // empty') + if [ -n "$CERT_ID" ]; then + echo " 证书申请成功: ID=$CERT_ID" + else + echo " 证书申请失败: $CERT_RESULT" + fi + sleep 5 +done +``` + +--- + +## 4. NPM 代理主机配置 + +创建反向代理主机,将域名转发到容器组内部服务,并启用 SSL。 + +### 4.1 获取证书 ID 映射 + +```bash +CERTS=$(curl -s "http://$EIP:81/api/nginx/certificates" \ + -H "Authorization: Bearer $TOKEN") +``` + +### 4.2 创建代理主机 + +根据 eci.conf 中的代理配置,自动创建代理主机。代理规则从 `NPM_PROXY_CONFIG` 读取: + +```bash +# NPM_PROXY_CONFIG 格式: 域名:端口:WebSocket +# 例如: www.d8d.fun:3001:false,next-socket.d8d.fun:3002:true,next-minio.d8d.fun:9000:false +``` + +```bash +for PROXY in $(echo $NPM_PROXY_CONFIG | tr ',' ' '); do + DOMAIN=$(echo $PROXY | cut -d: -f1) + PORT=$(echo $PROXY | cut -d: -f2) + WS=$(echo $PROXY | cut -d: -f3) + + CERT_ID=$(echo "$CERTS" | jq -r "[.[] | select(.domain_names[]==\"$DOMAIN\")] | .[0].id // empty") + + if [ -z "$CERT_ID" ]; then + echo "警告: $DOMAIN 无 SSL 证书,跳过代理创建" + continue + fi + + BODY=$(jq -n \ + --arg domain "$DOMAIN" \ + --argjson port "$PORT" \ + --argjson ws "$WS" \ + --argjson cert_id "$CERT_ID" \ + '{ + domain_names: [$domain], + forward_host: "127.0.0.1", + forward_port: $port, + ssl_forced: true, + certificate_id: $cert_id, + http2_support: true, + block_exploits: true, + websockets_support: $ws, + hsts_enabled: (if $ws then false else true end), + hsts_subdomains: false + }') + + RESULT=$(curl -s -X POST "http://$EIP:81/api/nginx/proxy-hosts" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$BODY") + + HOST_ID=$(echo "$RESULT" | jq -r '.id // empty') + if [ -n "$HOST_ID" ]; then + echo "代理主机创建成功: $DOMAIN -> 127.0.0.1:$PORT (SSL, WS=$WS)" + else + echo "代理主机创建失败: $RESULT" + fi +done +``` + +--- + +## 5. 验证 HTTPS 访问 + +```bash +for DOMAIN in $(echo $DNS_DOMAINS | tr ',' ' '); do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://$DOMAIN" 2>/dev/null || echo "000") + if [ "$STATUS" != "000" ]; then + echo "https://$DOMAIN - HTTP $STATUS" + else + echo "https://$DOMAIN - 不可达(DNS 可能尚未生效)" + fi +done +``` + +--- + +## 本阶段输出 + +| 状态项 | 说明 | +|--------|------| +| ✅ DNS A 记录 | 所有域名指向固定 EIP | +| ✅ NPM 管理员 | 自定义帐号已配置 | +| ✅ SSL 证书 | Let's Encrypt 证书已申请 | +| ✅ 代理主机 | HTTPS 反向代理已配置 | + +--- + +## 首次部署 vs 增量部署 + +| 操作 | 首次部署 | 增量部署 | +|------|----------|----------| +| DNS A 记录 | ✅ 创建 | ⏭️ 跳过(已存在) | +| NPM 帐号初始化 | ✅ 修改默认帐号 | ⏭️ 跳过 | +| SSL 证书申请 | ✅ 申请新证书 | ⏭️ 复用已有(自动续期) | +| 代理主机创建 | ✅ 创建 | ⏭️ 复用已有 | + +→ 返回 [workflow.md](../workflow.md) diff --git a/.claude/skills/d8d-release/reference/common-commands.md b/.claude/skills/d8d-release/reference/common-commands.md index bd547d7..d63216e 100644 --- a/.claude/skills/d8d-release/reference/common-commands.md +++ b/.claude/skills/d8d-release/reference/common-commands.md @@ -4,6 +4,32 @@ ## 1. 版本发布 +### CI 管理工具 + +使用 `scripts/ci.py` 统一管理 CI 构建: + +```bash +# 查看最近构建状态 +python3 scripts/ci.py status + +# 查看构建日志 +python3 scripts/ci.py log + +# 查看构建日志(原始 JSON) +python3 scripts/ci.py log --raw + +# 日志写入文件 +python3 scripts/ci.py log -o log.txt + +# 推送 tag 触发构建 +python3 scripts/ci.py build 0.2.0 + +# 列出已推送的 tag +python3 scripts/ci.py tags +``` + +> **配置来源**:从 `eci.conf` 的 `GITEA_*` 字段读取,无需额外配置文件。自动安装缺失的 `python3-requests` 依赖。 + ### 创建发布标签 ```bash @@ -328,6 +354,16 @@ fi ## 8. 配置验证 +### 资源组参数初始化 + +> 当 RAM 子帐号绑定资源组时,所有 `aliyun` 资源操作需要传 `--ResourceGroupId`。执行前先初始化: + +```bash +source eci.conf +RG_PARAM="" +[ -n "$ECI_RESOURCE_GROUP_ID" ] && RG_PARAM="--ResourceGroupId $ECI_RESOURCE_GROUP_ID" +``` + ### 检查 eci.conf 配置完整性 ```bash diff --git a/.claude/skills/d8d-release/reference/eci-container-spec.md b/.claude/skills/d8d-release/reference/eci-container-spec.md index a9b18f1..4384e14 100644 --- a/.claude/skills/d8d-release/reference/eci-container-spec.md +++ b/.claude/skills/d8d-release/reference/eci-container-spec.md @@ -8,12 +8,12 @@ | 配置项 | 值 | 说明 | |--------|-----|------| -| 总 CPU | 2.5 vCPU | 应用 2.0 + PostgreSQL 0.5 | -| 总内存 | 3.5 GiB | 应用 2.5 + PostgreSQL 1.0 | -| 容器数量 | 2 | 应用 + 数据库 sidecar | +| 总 CPU | 3.5 vCPU | 应用 2.0 + PostgreSQL 0.5 + MinIO 0.5 + NPM 0.5 | +| 总内存 | 5.5 GiB | 应用 2.5 + PostgreSQL 1.5 + MinIO 1.0 + NPM 0.5 | +| 容器数量 | 4 | 应用 + 数据库 + 对象存储 + 反向代理 | | 重启策略 | Always | 崩溃自动重启 | | 外网模式 | 绑定已有 EIP | IP 固定不变 | -| 镜像仓库 | 私有 Registry 内网拉取 | <1 秒 | +| 镜像仓库 | ACR VPC 或私有 Registry 内网拉取 | <1 秒(根据 REGISTRY_MODE 选择) | --- diff --git a/.claude/skills/d8d-release/rules/iron-laws.md b/.claude/skills/d8d-release/rules/iron-laws.md index ed7ee75..67e3de2 100644 --- a/.claude/skills/d8d-release/rules/iron-laws.md +++ b/.claude/skills/d8d-release/rules/iron-laws.md @@ -108,23 +108,14 @@ ## 铁律 6: 凭证不上传,配置不提交 -**原则**:敏感凭证绝不提交到代码仓库。 +**原则**:敏感凭证绝不提交到**公开**代码仓库。私有仓库可根据实际情况决定。 **执行保障**: -1. `eci.conf` 包含 `ALI_ACCESS_KEY_SECRET` → 必须加入 `.gitignore` +1. `eci.conf` 包含 `ALI_ACCESS_KEY_SECRET` → 公开仓库必须加入 `.gitignore`,私有仓库可提交 2. `.env` 包含数据库密码、API Key → 必须加入 `.gitignore` 3. CI 工作流中不硬编码任何密钥 4. 所有凭证通过环境变量或本地配置文件注入 -**Git 安全检查**(每次提交前执行): -```bash -# 检查是否意外提交了敏感文件 -git diff --cached --name-only | grep -E "(eci\.conf|\.env)" - -# 检查是否硬编码了 AK/SK -git diff --cached | grep -E "ALI_ACCESS_KEY|sk-" -``` - --- ## 铁律 7: 端口最小开放原则 @@ -158,19 +149,72 @@ git diff --cached | grep -E "ALI_ACCESS_KEY|sk-" --- -## 铁律 9: 部署后必须验证应用可用 +## 铁律 9: DNS 域名与 EIP 绑定 -**原则**:容器 Running ≠ 应用可用。部署流程必须验证完整检查链通过。 +**原则**:DNS A 记录创建后,域名与固定 EIP 永久绑定,域名不变、EIP 不变。 **执行保障**: -1. 容器组状态 Running → 仅代表容器进程启动 -2. 检查每个容器 `Ready=True` & `RestartCount=0` → 容器级就绪 -3. 等待 PostgreSQL 接受连接 → 数据库就绪 -4. 执行 `prisma db push` → Schema 同步 -5. 轮询 `/api/health` 返回 HTTP 200 → 应用就绪 -6. 健康检查超时 → 输出容器 Events 和日志供诊断,不静默成功 +1. DNS 记录按域名创建,指向 `ECI_EIP_ADDRESS` +2. 已存在的 A 记录使用 `UpdateDomainRecord` 更新 Value(不重复创建) +3. EIP 固定不变(铁律 1),因此 DNS 记录无需更新 -**检查顺序**:容器组 Running → 容器 Ready=True & RestartCount=0 → PostgreSQL 就绪 → Schema 迁移 → HTTP /api/health 200 → 部署成功 +**用户价值**: +- HTTPS 证书自动续期 +- 客户端域名访问稳定 +- SSL 指纹与域名一致 + +--- + +## 铁律 10: NPM 代理配置持久化 + +**原则**:NPM 代理主机配置存储在 NAS,容器重建不丢失。 + +**执行保障**: +1. NPM 数据目录 `/data` 挂载 NAS +2. Let's Encrypt 证书目录 `/etc/letsencrypt` 挂载 NAS +3. 代理规则、SSL 证书、访问列表全部持久化 + +**数据清单**: +| 数据类型 | 存储位置 | 持久化保障 | +|----------|----------|------------| +| 代理主机规则 | NAS /data | ✅ 容器重建保留 | +| SSL 证书 | NAS /etc/letsencrypt | ✅ 容器重建保留 | +| 访问列表 | NAS /data | ✅ 容器重建保留 | + +--- + +## 铁律 11: RAM 资源组感知 + +**原则**:当 RAM 子帐号绑定资源组时,所有资源创建和查询 API 必须传 `--ResourceGroupId`,否则会权限不足或查询不到已有资源。 + +**执行保障**: +1. `eci.conf` 中 `ECI_RESOURCE_GROUP_ID` 非空时,自动构建 `RG_PARAM="--ResourceGroupId $ECI_RESOURCE_GROUP_ID"` +2. 所有 `aliyun` 资源创建命令(VPC、VSwitch、SecurityGroup、NAS、EIP)附加 `$RG_PARAM` +3. 所有 `aliyun` 资源查询命令(DescribeVpcs、DescribeContainerGroups 等)附加 `$RG_PARAM` +4. ECI 容器组操作(Create/Delete/Describe)附加 `$RG_PARAM` +5. 为空时不传,兼容无资源组绑定的 RAM 帐号 + +**例外**: +- `CreateVSwitch` 不支持 `--ResourceGroupId`(继承 VPC 的资源组) +- 安全组规则操作(Authorize/RevokeSecurityGroup)无需传 + +**Why**:`eci-d8d-next` RAM 子帐号绑定资源组 `rg-aek6bbbehek5tnq`,不带此参数的 API 调用返回空结果或权限错误,导致误创建重复资源。 + +**How to apply**:在 phase-01 和部署脚本执行前初始化 `RG_PARAM`,后续所有 aliyun 命令统一附加。 + +### 铁律 12: NAS 子目录挂载前必须预创建 + +ECI NFS 挂载指定子路径时,该路径必须在 NAS 上存在,否则 `MountVolume.SetUp` 失败,容器组卡在 Pending。 + +**操作**:部署前用 `aliyun nas CreateDir` 预创建所有挂载目录: +```bash +aliyun nas CreateDir --FileSystemId $NAS_FS_ID --RootDirectory /d8d-next/pgdata \ + --OwnerUserId 0 --OwnerGroupId 0 --Permission 0755 --RegionId $ECI_REGION +``` + +**Why**:ECI 容器组创建时如果 NAS 子目录不存在,4 个卷全部 `FailedMount: file does not exist`,容器无法启动。`CreateDir` 幂等操作,目录已存在不会报错。 + +**How to apply**:在 `eci-deploy.sh` 创建容器组之前,自动调用 `CreateDir` 确保目录存在。phase-01 创建 NAS 后也需执行此步骤。 --- diff --git a/.claude/skills/d8d-release/workflow.md b/.claude/skills/d8d-release/workflow.md index 7ac605d..9d80d7c 100644 --- a/.claude/skills/d8d-release/workflow.md +++ b/.claude/skills/d8d-release/workflow.md @@ -6,7 +6,7 @@ | 优势 | 说明 | |------|------| -| ✅ 完全自托管 | 不依赖 ACR/Docker Hub,所有镜像私有存储 | +| ✅ 完全自托管 | 支持 ACR 或自建 Registry,所有镜像私有存储 | | ✅ Spot 实例成本 | Registry 使用抢占式实例,相比按量节省 90% | | ✅ NAS 持久化 | 数据 + 镜像都存 NAS,容器重建不丢失 | | ✅ 双固定 EIP | 应用 (3001) + Registry (5000),IP 永久不变 | @@ -67,7 +67,19 @@ | 02 | `phases/phase-02-config.md` | 配置生成(eci.conf 填充 | 03 | `phases/phase-03-ci-setup.md` | CI 工作流 + 脚本部署 | 04 | `phases/phase-04-deploy.md` | 触发发布 + ECI 容器组部署 -| 05 | `phases/phase-05-verify.md` | 部署验证 + 健康检查 +| 05 | `phases/phase-05-verify.md` | 部署验证 + 健康检查 | +| 06 | `phases/phase-06-dns-npm-ssl.md` | DNS + NPM 代理 + SSL 证书配置 | + +## 核心工具 + +| 工具 | 路径 | 用途 | +|------|------|------| +| `ci.py` | `scripts/ci.py` | CI 状态查看、构建日志、触发构建、tag 管理 | +| `release_tag.sh` | `scripts/release_tag.sh` | 创建 git tag 触发 CI | +| `eci-deploy.sh` | `scripts/eci-deploy.sh` | ECI 容器组部署脚本 | +| `eci-registry.sh` | `scripts/eci-registry.sh` | 私有 Registry 部署脚本 | + +> **CI 管理首选 `ci.py`**:`python3 scripts/ci.py status` 查看构建状态、`python3 scripts/ci.py log ` 查看日志、`python3 scripts/ci.py build ` 触发构建。自动安装依赖,从 `eci.conf` 读配置。 ## 铁律与优化