diff --git a/.env.example b/.env.example index 8969c14..7e39b87 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,11 @@ ANTHROPIC_BASE_URL=https://api.anthropic.com # 服务端口(默认 3000) PORT=3000 + +# K8S 部署配置 +K8S_API_URL=http://10.0.0.52:23972 +K8S_API_TOKEN=your_fixed_token_here + +# 域名配置 +DEV_HOST="*.dev.d8d.fun" +DEV_TLS_SECRET_NAME="dev-d8d-tls" diff --git a/_bmad-output/implementation-artifacts/tech-spec-k8s-deploy.md b/_bmad-output/implementation-artifacts/tech-spec-k8s-deploy.md new file mode 100644 index 0000000..2ddcf60 --- /dev/null +++ b/_bmad-output/implementation-artifacts/tech-spec-k8s-deploy.md @@ -0,0 +1,167 @@ +--- +title: 'K8S 部署脚本' +slug: 'k8s-deploy' +created: '2026-03-20T07:00:00.000Z' +status: 'ready-for-dev' +stepsCompleted: [1, 2, 3, 4] +tech_stack: ['TypeScript', '@kubernetes/client-node', 'Node.js', 'dotenv', 'express'] +files_to_modify: + - 'package.json' + - 'scripts/deploy.ts' +code_patterns: + - 'K8S 客户端: @kubernetes/client-node SDK 封装' + - '部署流程: Deployment → Service → Ingress' + - 'EIP: k8s.aliyun.com/eci-open-enable-eip 自动创建' + - '抢占式实例: 未启用(可选优化)' + - '命名空间: d8d-proxy (专用命名空间)' + - '实例规格: ecs.t5-lc2m1.nano (完整字符串)' + - 'Ingress 域名: {name}-{port}.{hostSuffix}' +test_patterns: [] +--- + +# Tech-Spec: K8S 部署脚本 + +**Created:** 2026-03-20T07:00:00.000Z + +## Overview + +### Problem Statement + +Claude API 代理服务已完成,需要添加 K8S 部署脚本来动态创建和管理 Pod 实例。每个 Pod 需要独立的出口 IP (通过 EIP),以便分散请求绕过官方 API 的 IP 并发限制。 + +### Solution + +1. 安装 `@kubernetes/client-node` 依赖 +2. 使用已实现的 `src/k8s-client.ts` K8S 客户端封装 +3. 使用已实现的 `src/deploy.ts` 部署模块 +4. 创建 `scripts/deploy.ts` 命令行入口 + +### Scope + +**In Scope:** +- 安装 @kubernetes/client-node 依赖 +- 命令行部署工具 +- 部署脚本:创建/删除 Deployment、Service、Ingress +- 自动 EIP 创建支持 +- 专用命名空间 `d8d-proxy` +- 健康检查配置 + +**Out of Scope:** +- API Key 池管理(由 D8D 主项目处理) +- 复杂的监控和告警 +- HPA 自动扩缩容 + +## Context for Development + +### Codebase Patterns + +**已实现的文件:** + +| 文件 | 状态 | 描述 | +|------|------|------| +| `src/k8s-client.ts` | ✅ 已完成 | K8S SDK 封装类 | +| `src/deploy.ts` | ✅ 已完成 | 部署/删除/状态查询功能 | +| `.env.example` | ✅ 已完成 | K8S 环境变量配置 | +| `package.json` | ❌ 缺少依赖 | 需添加 @kubernetes/client-node | +| `scripts/deploy.ts` | ❌ 未创建 | 命令行入口 | + +**阿里云 ECI 注解(已实现写法):** +- `k8s.aliyun.com/eci-use-specs: "ecs.t5-lc2m1.nano"` - 指定 ECI 规格 +- `k8s.aliyun.com/eci-open-enable-eip: "true"` - 自动创建 EIP +- `k8s.aliyun.com/eci-eip-bandwidth: "5"` - EIP 带宽 5Mbps +- `k8s.aliyun.com/eci-eip-internet-charge-type: "PayByBandwidth"` - 按带宽计费 + +### Files to Reference + +| File | Purpose | +| ---- | ------- | +| `src/k8s-client.ts` | K8S SDK 封装(已实现) | +| `src/deploy.ts` | 部署模块(已实现) | + +### Technical Decisions + +- **命名空间**: `d8d-proxy` (专用,避免与 d8d-dev/d8d-prd 冲突) +- **K8S 客户端**: 使用 @kubernetes/client-node SDK +- **认证方式**: K8S_API_URL + K8S_API_TOKEN 环境变量 +- **EIP 配置**: 自动创建(通过 ECI annotation) +- **容器规格**: `ecs.t5-lc2m1.nano` (0.5 CPU, 1G 内存) +- **Ingress 域名**: `{name}-{port}.{hostSuffix}` +- **镜像**: registry.cn-beijing.aliyuncs.com/d8dcloud/claude-api-proxy:${VERSION} +- **健康检查**: /health 端点 +- **Ingress**: 无需 SSO 认证(代理服务) + +## Implementation Plan + +### Tasks + +**Task 1: 安装 K8S 依赖** +- [ ] 执行 `pnpm add @kubernetes/client-node` +- [ ] 验证依赖安装成功 + +**Task 2: 创建命令行入口** +- [ ] File: `scripts/deploy.ts` +- [ ] Action: 创建命令行工具,支持以下命令: + - `deploy ` - 部署代理服务 + - `delete ` - 删除代理服务 + - `status ` - 查询服务状态 +- [ ] Notes: 使用 tsx 运行,参考参数: + - `--replicas` 副本数(默认 1) + - `--port` 端口(默认 3000) + - `--eip-bandwidth` EIP 带宽(默认 5) + +**Task 3: 添加 npm 脚本** +- [ ] File: `package.json` +- [ ] Action: 添加部署相关脚本: + ```json + "deploy": "tsx scripts/deploy.ts" + ``` + +### Acceptance Criteria + +- [ ] **AC1: 依赖安装成功** + - Given: 执行 pnpm install + - When: 安装 @kubernetes/client-node + - Then: 依赖正确安装,TypeScript 编译无错误 + +- [ ] **AC2: 部署命令可执行** + - Given: 环境变量 K8S_API_URL 和 K8S_API_TOKEN 已配置 + - When: 执行 `pnpm deploy ` + - Then: 成功创建 Deployment、Service、Ingress,返回访问 URL + +- [ ] **AC3: EIP 自动创建** + - Given: 部署时启用 EIP 选项(默认启用) + - When: Pod 启动 + - Then: 自动创建并绑定 EIP + +- [ ] **AC4: 删除功能正常** + - Given: 已部署的代理服务 + - When: 执行 `pnpm deploy delete ` + - Then: 清理所有 K8S 资源(Deployment、Service、Ingress) + +- [ ] **AC5: 状态查询正常** + - Given: 已部署的代理服务 + - When: 执行 `pnpm deploy status ` + - Then: 返回服务运行状态和访问 URL + +## Additional Context + +### Dependencies + +**新增依赖:** +- @kubernetes/client-node - K8S 官方 Node.js SDK + +### Testing Strategy + +**手动测试步骤:** +1. 配置 `.env` 文件中的 K8S 环境变量 +2. 执行 `pnpm deploy claude-proxy-1 registry.cn-beijing.aliyuncs.com/d8dcloud/claude-api-proxy:v0.0.1` +3. 验证 Deployment、Service、Ingress 创建成功 +4. 访问返回的 HTTPS URL 验证服务可用 +5. 执行删除命令验证资源清理 + +### Notes + +- 命名空间独立,避免与 d8d-dev/d8d-prd 冲突 +- 代理服务无需 SSO 认证 +- EIP 自动创建简化了 IP 管理 +- 当前未启用抢占式实例,后续可优化成本 diff --git a/package.json b/package.json index 27f465d..2452720 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "build": "tsc && tsc-alias", "start": "node dist/index.js", - "dev": "tsx watch src/index.ts" + "dev": "tsx watch src/index.ts", + "deploy": "tsx scripts/deploy.ts" }, "keywords": [ "claude", @@ -16,6 +17,7 @@ "author": "", "license": "MIT", "dependencies": { + "@kubernetes/client-node": "1.3.0", "axios": "^1.7.9", "dotenv": "^16.4.7", "express": "^4.21.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a662423..b6dbaf8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@kubernetes/client-node': + specifier: 1.3.0 + version: 1.3.0 axios: specifier: ^1.7.9 version: 1.13.6 @@ -192,6 +195,21 @@ packages: cpu: [x64] os: [win32] + '@jsep-plugin/assignment@1.3.0': + resolution: {integrity: sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==} + engines: {node: '>= 10.16.0'} + peerDependencies: + jsep: ^0.4.0||^1.0.0 + + '@jsep-plugin/regex@1.0.4': + resolution: {integrity: sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==} + engines: {node: '>= 10.16.0'} + peerDependencies: + jsep: ^0.4.0||^1.0.0 + + '@kubernetes/client-node@1.3.0': + resolution: {integrity: sha512-IE0yrIpOT97YS5fg2QpzmPzm8Wmcdf4ueWMn+FiJSI3jgTTQT1u+LUhoYpdfhdHAVxdrNsaBg2C0UXSnOgMoCQ==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -219,6 +237,12 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + '@types/node@22.19.15': resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} @@ -234,14 +258,24 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/stream-buffers@3.0.8': + resolution: {integrity: sha512-J+7VaHKNvlNPJPEJXX/fKa9DZtR/xPMwuIbe+yNOwp1YB+ApUOBv2aUpEoBJEi8nJgbgs1x8e73ttg0r1rSUdw==} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} @@ -255,6 +289,52 @@ packages: axios@1.13.6: resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + b4a@1.8.0: + resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.5.6: + resolution: {integrity: sha512-1QovqDrR80Pmt5HPAsMsXTCFcDYr+NSUKW6nd6WO5v0JBmnItc/irNRzm2KOQ5oZ69P37y+AMujNyNtG+1Rggw==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.8.0: + resolution: {integrity: sha512-Dc9/SlwfxkXIGYhvMQNUtKaXCaGkZYGcd1vuNUUADVqzu4/vQfvnMkYYOUnt2VwQ2AqKr/8qAVFRtwETljgeFg==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.10.0: + resolution: {integrity: sha512-DOPZF/DDcDruKDA43cOw6e9Quq5daua7ygcAwJE/pKJsRWhgSSemi7qVNGE5kyDIxIeN1533G/zfbvWX7Wcb9w==} + peerDependencies: + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.0: + resolution: {integrity: sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -314,6 +394,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -345,6 +434,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -373,10 +465,16 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + express@4.22.1: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -456,6 +554,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hpagent@1.2.0: + resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==} + engines: {node: '>=14'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -471,6 +573,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -491,6 +597,27 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsep@1.4.0: + resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==} + engines: {node: '>= 10.16.0'} + + jsonpath-plus@10.4.0: + resolution: {integrity: sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==} + engines: {node: '>=18.0.0'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -541,10 +668,22 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + oauth4webapi@3.8.5: + resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -553,6 +692,12 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + openid-client@6.8.2: + resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -579,6 +724,9 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + qs@6.14.2: resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} @@ -609,6 +757,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfc4648@1.5.4: + resolution: {integrity: sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -649,10 +800,41 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + stream-buffers@3.0.3: + resolution: {integrity: sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==} + engines: {node: '>= 0.10.0'} + + streamx@2.25.0: + resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} + + tar-fs@3.1.2: + resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==} + + tar-stream@3.1.8: + resolution: {integrity: sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -661,6 +843,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tsc-alias@1.8.16: resolution: {integrity: sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==} engines: {node: '>=16.20.2'} @@ -695,6 +880,27 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + snapshots: '@esbuild/aix-ppc64@0.27.4': @@ -775,6 +981,41 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true + '@jsep-plugin/assignment@1.3.0(jsep@1.4.0)': + dependencies: + jsep: 1.4.0 + + '@jsep-plugin/regex@1.0.4(jsep@1.4.0)': + dependencies: + jsep: 1.4.0 + + '@kubernetes/client-node@1.3.0': + dependencies: + '@types/js-yaml': 4.0.9 + '@types/node': 22.19.15 + '@types/node-fetch': 2.6.13 + '@types/stream-buffers': 3.0.8 + form-data: 4.0.5 + hpagent: 1.2.0 + isomorphic-ws: 5.0.0(ws@8.19.0) + js-yaml: 4.1.1 + jsonpath-plus: 10.4.0 + node-fetch: 2.7.0 + openid-client: 6.8.2 + rfc4648: 1.5.4 + socks-proxy-agent: 8.0.5 + stream-buffers: 3.0.3 + tar-fs: 3.1.2 + ws: 8.19.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - encoding + - react-native-b4a + - supports-color + - utf-8-validate + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -811,6 +1052,13 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/js-yaml@4.0.9': {} + + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 22.19.15 + form-data: 4.0.5 + '@types/node@22.19.15': dependencies: undici-types: 6.21.0 @@ -828,16 +1076,24 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 22.19.15 + '@types/stream-buffers@3.0.8': + dependencies: + '@types/node': 22.19.15 + accepts@1.3.8: dependencies: mime-types: 2.1.35 negotiator: 0.6.3 + agent-base@7.1.4: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 picomatch: 2.3.1 + argparse@2.0.1: {} + array-flatten@1.1.1: {} array-union@2.1.0: {} @@ -852,6 +1108,41 @@ snapshots: transitivePeerDependencies: - debug + b4a@1.8.0: {} + + bare-events@2.8.2: {} + + bare-fs@4.5.6: + dependencies: + bare-events: 2.8.2 + bare-path: 3.0.0 + bare-stream: 2.10.0(bare-events@2.8.2) + bare-url: 2.4.0 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.8.0: {} + + bare-path@3.0.0: + dependencies: + bare-os: 3.8.0 + + bare-stream@2.10.0(bare-events@2.8.2): + dependencies: + streamx: 2.25.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-url@2.4.0: + dependencies: + bare-path: 3.0.0 + binary-extensions@2.3.0: {} body-parser@1.20.4: @@ -919,6 +1210,10 @@ snapshots: dependencies: ms: 2.0.0 + debug@4.4.3: + dependencies: + ms: 2.1.3 + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -941,6 +1236,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -989,6 +1288,12 @@ snapshots: etag@1.8.1: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + express@4.22.1: dependencies: accepts: 1.3.8 @@ -1025,6 +1330,8 @@ snapshots: transitivePeerDependencies: - supports-color + fast-fifo@1.3.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1119,6 +1426,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hpagent@1.2.0: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -1135,6 +1444,8 @@ snapshots: inherits@2.0.4: {} + ip-address@10.1.0: {} + ipaddr.js@1.9.1: {} is-binary-path@2.1.0: @@ -1149,6 +1460,24 @@ snapshots: is-number@7.0.0: {} + isomorphic-ws@5.0.0(ws@8.19.0): + dependencies: + ws: 8.19.0 + + jose@6.2.2: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsep@1.4.0: {} + + jsonpath-plus@10.4.0: + dependencies: + '@jsep-plugin/assignment': 1.3.0(jsep@1.4.0) + '@jsep-plugin/regex': 1.0.4(jsep@1.4.0) + jsep: 1.4.0 + math-intrinsics@1.1.0: {} media-typer@0.3.0: {} @@ -1180,14 +1509,29 @@ snapshots: negotiator@0.6.3: {} + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + normalize-path@3.0.0: {} + oauth4webapi@3.8.5: {} + object-inspect@1.13.4: {} on-finished@2.4.1: dependencies: ee-first: 1.1.1 + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + openid-client@6.8.2: + dependencies: + jose: 6.2.2 + oauth4webapi: 3.8.5 + parseurl@1.3.3: {} path-to-regexp@0.1.12: {} @@ -1207,6 +1551,11 @@ snapshots: proxy-from-env@1.1.0: {} + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + qs@6.14.2: dependencies: side-channel: 1.1.0 @@ -1232,6 +1581,8 @@ snapshots: reusify@1.1.0: {} + rfc4648@1.5.4: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -1299,14 +1650,78 @@ snapshots: slash@3.0.0: {} + smart-buffer@4.2.0: {} + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + socks: 2.8.7 + transitivePeerDependencies: + - supports-color + + socks@2.8.7: + dependencies: + ip-address: 10.1.0 + smart-buffer: 4.2.0 + statuses@2.0.2: {} + stream-buffers@3.0.3: {} + + streamx@2.25.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + tar-fs@3.1.2: + dependencies: + pump: 3.0.4 + tar-stream: 3.1.8 + optionalDependencies: + bare-fs: 4.5.6 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@3.1.8: + dependencies: + b4a: 1.8.0 + bare-fs: 4.5.6 + fast-fifo: 1.3.2 + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.0 + transitivePeerDependencies: + - react-native-b4a + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 toidentifier@1.0.1: {} + tr46@0.0.3: {} + tsc-alias@1.8.16: dependencies: chokidar: 3.6.0 @@ -1338,3 +1753,14 @@ snapshots: utils-merge@1.0.1: {} vary@1.1.2: {} + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + wrappy@1.0.2: {} + + ws@8.19.0: {} diff --git a/scripts/deploy.ts b/scripts/deploy.ts new file mode 100644 index 0000000..b65037c --- /dev/null +++ b/scripts/deploy.ts @@ -0,0 +1,173 @@ +#!/usr/bin/env node +/** + * K8S 部署命令行工具 + * 用于部署 Claude API 代理服务到 ECI + * + * 用法: + * pnpm deploy [options] - 部署代理服务 + * pnpm deploy delete - 删除代理服务 + * pnpm deploy status - 查询服务状态 + * + * 选项: + * --replicas 副本数 (默认: 1) + * --port 容器端口 (默认: 3000) + * --eip-bandwidth EIP 带宽 Mbps (默认: 5) + */ + +import { config } from 'dotenv'; +config(); + +import { deployProxy, deleteProxy, getProxyStatus } from '../src/deploy'; + +// 解析命令行参数 +function parseArgs() { + const args = process.argv.slice(2); + + if (args.length === 0) { + printUsage(); + process.exit(1); + } + + const command = args[0]; + + // 解析选项 + const getOption = (name: string, defaultValue: string): string => { + const idx = args.indexOf(name); + if (idx !== -1 && idx + 1 < args.length) { + return args[idx + 1]; + } + return defaultValue; + }; + + const getOptionNumber = (name: string, defaultValue: number): number => { + return Number(getOption(name, String(defaultValue))); + }; + + return { + command, + name: args[1], + image: args[2], + options: { + replicas: getOptionNumber('--replicas', 1), + port: getOptionNumber('--port', 3000), + eipBandwidth: getOptionNumber('--eip-bandwidth', 5), + }, + }; +} + +function printUsage() { + console.log(` +K8S 部署命令行工具 - Claude API 代理服务 + +用法: + pnpm deploy [options] 部署代理服务 + pnpm deploy delete 删除代理服务 + pnpm deploy status 查询服务状态 + +选项: + --replicas 副本数 (默认: 1) + --port 容器端口 (默认: 3000) + --eip-bandwidth EIP 带宽 Mbps (默认: 5) + +示例: + # 部署单个代理实例 + pnpm deploy claude-proxy-1 registry.cn-beijing.aliyuncs.com/d8dcloud/claude-api-proxy:v0.0.1 + + # 部署 3 个副本 + pnpm deploy claude-proxy-1 registry.cn-beijing.aliyuncs.com/d8dcloud/claude-api-proxy:v0.0.1 --replicas 3 + + # 删除服务 + pnpm deploy delete claude-proxy-1 + + # 查询状态 + pnpm deploy status claude-proxy-1 +`); +} + +async function main() { + const { command, name, image, options } = parseArgs(); + + // 检查环境变量 + if (!process.env.K8S_API_URL || !process.env.K8S_API_TOKEN) { + console.error('❌ 错误: 请配置 K8S_API_URL 和 K8S_API_TOKEN 环境变量'); + console.error(' 可以在 .env 文件中配置或通过环境变量传入'); + process.exit(1); + } + + try { + switch (command) { + case 'delete': { + if (!name) { + console.error('❌ 错误: 请指定服务名称'); + process.exit(1); + } + console.log(`🗑️ 正在删除服务 ${name}...`); + const result = await deleteProxy(name); + if (result.success) { + console.log(`✅ ${result.message}`); + } else { + console.error(`❌ ${result.message}`); + process.exit(1); + } + break; + } + + case 'status': { + if (!name) { + console.error('❌ 错误: 请指定服务名称'); + process.exit(1); + } + console.log(`📊 查询服务状态 ${name}...`); + const status = await getProxyStatus(name); + if (status.exists) { + console.log(`✅ 服务状态: ${status.status}`); + if (status.hosts && status.hosts.length > 0) { + console.log(`🌐 访问地址: ${status.hosts.join(', ')}`); + } + } else { + console.log(`⚠️ 服务 ${name} 不存在`); + } + break; + } + + default: { + // deploy 命令 + if (!name || !image) { + console.error('❌ 错误: 请指定服务名称和镜像'); + process.exit(1); + } + + console.log(`🚀 正在部署 ${name}...`); + console.log(` 镜像: ${image}`); + console.log(` 副本: ${options.replicas}`); + console.log(` 端口: ${options.port}`); + console.log(` EIP 带宽: ${options.eipBandwidth} Mbps`); + + const result = await deployProxy({ + name, + image, + replicas: options.replicas, + port: options.port, + eipBandwidth: options.eipBandwidth, + }); + + if (result.success) { + console.log(`\n✅ ${result.message}`); + if (result.hosts && result.hosts.length > 0) { + console.log(`\n🌐 访问地址:`); + result.hosts.forEach(host => console.log(` ${host}`)); + } + } else { + console.error(`\n❌ ${result.message}`); + process.exit(1); + } + break; + } + } + } catch (error) { + console.error('❌ 执行失败:', error instanceof Error ? error.message : error); + process.exit(1); + } +} + +main(); diff --git a/src/deploy.ts b/src/deploy.ts new file mode 100644 index 0000000..0c44a73 --- /dev/null +++ b/src/deploy.ts @@ -0,0 +1,373 @@ +/** + * K8S 部署模块 - 用于部署 Claude API 代理服务到 ECI + */ + +import { config } from 'dotenv'; +config(); + +import { K8sClient, V1Deployment, V1Ingress, V1IngressRule, V1ServicePort } from './k8s-client'; + +// 环境变量 +const K8S_API_URL = process.env.K8S_API_URL || ''; +const K8S_API_TOKEN = process.env.K8S_API_TOKEN || ''; +const DEV_HOST = process.env.DEV_HOST || 'dev.d8d.fun'; +const TLS_SECRET_NAME = process.env.TLS_SECRET_NAME || 'd8d-tls'; + +// 专用命名空间 +const NAMESPACE = 'd8d-proxy'; + +// 初始化 K8S 客户端 +export const k8sClient = new K8sClient(K8S_API_URL, K8S_API_TOKEN); + +export interface DeployProxyParams { + /** 容器组名称(唯一标识) */ + name: string; + /** Docker 镜像 */ + image: string; + /** 副本数 */ + replicas?: number; + /** 容器端口 */ + port?: number; + /** ECI 实例规格 */ + instanceType?: string; + /** 是否自动创建 EIP */ + autoEip?: boolean; + /** EIP 带宽 (Mbps) */ + eipBandwidth?: number; + /** 环境变量 */ + env?: Record; + /** 标签 */ + labels?: Record; + /** 域名后缀 */ + hostSuffix?: string; +} + +export interface DeployProxyResult { + success: boolean; + message: string; + hosts?: string[]; + deploymentName?: string; + serviceName?: string; + ingressName?: string; +} + +/** + * 部署 Claude API 代理服务 + */ +export async function deployProxy(params: DeployProxyParams): Promise { + const { + name, + image, + replicas = 1, + port = 3000, + instanceType = 'ecs.t5-lc2m1.nano', + autoEip = true, + eipBandwidth = 5, + env = {}, + labels = {}, + hostSuffix = DEV_HOST, + } = params; + + const deploymentName = name; + const serviceName = `service-${name}`; + const ingressName = `${name}-ingress`; + + // 清理域名后缀 + const cleanHostSuffix = hostSuffix.replace(/^\*\./, '').replace(/^\.+/, ''); + + // 构建域名 + const host = `${name}-${port}.${cleanHostSuffix}`; + + try { + // 1. 创建 Deployment + const deployment: V1Deployment = { + metadata: { + name: deploymentName, + labels: { + app: name, + 'proxy-container': 'true', + ...labels, + }, + annotations: { + // ECI 实例规格 + 'k8s.aliyun.com/eci-use-specs': instanceType, + // 自动创建 EIP + ...(autoEip && { + 'k8s.aliyun.com/eci-open-enable-eip': 'true', + 'k8s.aliyun.com/eci-eip-bandwidth': String(eipBandwidth), + 'k8s.aliyun.com/eci-eip-internet-charge-type': 'PayByBandwidth', + }), + }, + }, + spec: { + replicas, + selector: { + matchLabels: { + app: name, + }, + }, + template: { + metadata: { + labels: { + app: name, + 'proxy-container': 'true', + ...labels, + }, + annotations: { + // ECI 实例规格 + 'k8s.aliyun.com/eci-use-specs': instanceType, + // 自动创建 EIP + ...(autoEip && { + 'k8s.aliyun.com/eci-open-enable-eip': 'true', + 'k8s.aliyun.com/eci-eip-bandwidth': String(eipBandwidth), + 'k8s.aliyun.com/eci-eip-internet-charge-type': 'PayByBandwidth', + }), + }, + }, + spec: { + automountServiceAccountToken: false, + enableServiceLinks: false, + nodeSelector: { + type: 'virtual-kubelet', + }, + containers: [ + { + name: name, + image: image, + ports: [ + { + containerPort: port, + protocol: 'TCP', + }, + ], + env: Object.entries(env).map(([key, value]) => ({ + name: key, + value, + })), + resources: { + requests: { + cpu: '250m', + memory: '256Mi', + }, + }, + readinessProbe: { + httpGet: { + path: '/health', + port: port, + }, + initialDelaySeconds: 5, + periodSeconds: 10, + }, + livenessProbe: { + httpGet: { + path: '/health', + port: port, + }, + initialDelaySeconds: 15, + periodSeconds: 20, + }, + }, + ], + tolerations: [ + { + key: 'alibabacloud.com/eci', + operator: 'Exists', + effect: 'NoSchedule', + }, + ], + }, + }, + }, + }; + + await k8sClient.createDeployment({ + json: { + namespace: NAMESPACE, + deployment, + }, + }); + + console.log(`✅ Deployment ${deploymentName} 创建成功`); + + // 2. 创建 Service + const ports: V1ServicePort[] = [ + { + port: port, + targetPort: port, + name: 'http', + }, + ]; + + await k8sClient.createService({ + json: { + namespace: NAMESPACE, + service: { + metadata: { + name: serviceName, + labels: { + app: name, + }, + }, + spec: { + type: 'ClusterIP', + selector: { + app: name, + }, + ports, + }, + }, + }, + }); + + console.log(`✅ Service ${serviceName} 创建成功`); + + // 3. 创建 Ingress + const ingressRule: V1IngressRule = { + host, + http: { + paths: [ + { + path: '/', + pathType: 'Prefix', + backend: { + service: { + name: serviceName, + port: { + number: port, + }, + }, + }, + }, + ], + }, + }; + + const ingress: V1Ingress = { + metadata: { + name: ingressName, + annotations: { + 'nginx.ingress.kubernetes.io/proxy-body-size': '50m', + 'nginx.ingress.kubernetes.io/proxy-read-timeout': '300', + 'nginx.ingress.kubernetes.io/proxy-send-timeout': '300', + }, + }, + spec: { + tls: [ + { + hosts: [host], + secretName: TLS_SECRET_NAME, + }, + ], + rules: [ingressRule], + }, + }; + + await k8sClient.createIngress({ + json: { + namespace: NAMESPACE, + ingress, + }, + }); + + console.log(`✅ Ingress ${ingressName} 创建成功`); + + return { + success: true, + message: `代理服务 ${name} 部署成功`, + hosts: [`https://${host}`], + deploymentName, + serviceName, + ingressName, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`❌ 部署失败: ${errorMessage}`); + return { + success: false, + message: `部署失败: ${errorMessage}`, + }; + } +} + +/** + * 删除代理服务 + */ +export async function deleteProxy(name: string): Promise<{ success: boolean; message: string }> { + const ingressName = `${name}-ingress`; + const serviceName = `service-${name}`; + + try { + // 1. 删除 Ingress + await k8sClient.deleteIngress({ + param: { + namespace: NAMESPACE, + ingressName, + }, + }).catch(err => console.warn(`删除 Ingress 失败: ${err.message}`)); + + // 2. 删除 Service + await k8sClient.deleteService({ + param: { + namespace: NAMESPACE, + serviceName, + }, + }).catch(err => console.warn(`删除 Service 失败: ${err.message}`)); + + // 3. 删除 Deployment + await k8sClient.deleteDeployment({ + param: { + namespace: NAMESPACE, + name, + }, + }).catch(err => console.warn(`删除 Deployment 失败: ${err.message}`)); + + console.log(`✅ 代理服务 ${name} 删除成功`); + return { success: true, message: `代理服务 ${name} 删除成功` }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { success: false, message: `删除失败: ${errorMessage}` }; + } +} + +/** + * 获取代理服务状态 + */ +export async function getProxyStatus(name: string): Promise<{ + exists: boolean; + status?: string; + hosts?: string[]; +}> { + try { + const deployment = await k8sClient.getDeployment({ + param: { + namespace: NAMESPACE, + name, + }, + }); + + const readyReplicas = deployment.status?.readyReplicas || 0; + const replicas = deployment.spec?.replicas || 1; + + // 获取 Ingress 域名 + const ingressResult = await k8sClient.getIngress({ + param: { + namespace: NAMESPACE, + ingressName: `${name}-ingress`, + }, + }); + + const hosts = ingressResult.ingress?.spec?.rules?.map(r => `https://${r.host}`) || []; + + return { + exists: true, + status: readyReplicas >= replicas ? 'Running' : 'Pending', + hosts, + }; + } catch (error) { + const errorCode = (error as { code?: number })?.code; + if (errorCode === 404) { + return { exists: false }; + } + throw error; + } +} diff --git a/src/k8s-client.ts b/src/k8s-client.ts new file mode 100644 index 0000000..0ae19fa --- /dev/null +++ b/src/k8s-client.ts @@ -0,0 +1,138 @@ +import * as k8s from '@kubernetes/client-node'; + +export type V1Deployment = k8s.V1Deployment; +export type V1DeploymentList = k8s.V1DeploymentList; +export type V1Ingress = k8s.V1Ingress; +export type V1IngressRule = k8s.V1IngressRule; +export type V1Pod = k8s.V1Pod; +export type V1Service = k8s.V1Service; +export type V1ServicePort = k8s.V1ServicePort; + +export class K8sClient { + private kubeConfig: k8s.KubeConfig; + + constructor(baseUrl: string, token: string) { + this.kubeConfig = new k8s.KubeConfig(); + this.kubeConfig.loadFromOptions({ + clusters: [{ name: 'cluster', server: baseUrl }], + users: [{ name: 'user', authProvider: { name: 'token', config: { token } } }], + contexts: [{ name: 'ctx', cluster: 'cluster', user: 'user' }], + currentContext: 'ctx', + }); + } + + private getCoreV1Api() { + return this.kubeConfig.makeApiClient(k8s.CoreV1Api); + } + + private getAppsV1Api() { + return this.kubeConfig.makeApiClient(k8s.AppsV1Api); + } + + private getNetworkingV1Api() { + return this.kubeConfig.makeApiClient(k8s.NetworkingV1Api); + } + + async getPods(args: { param: { namespace?: string }; query?: { labelSelector?: string; fieldSelector?: string; limit?: number; continue?: string } }): Promise<{ pods: k8s.V1Pod[] }> { + try { + const ns = args.param.namespace || 'default'; + const res = await this.getCoreV1Api().listNamespacedPod({ namespace: ns, labelSelector: args.query?.labelSelector, fieldSelector: args.query?.fieldSelector, limit: args.query?.limit, _continue: args.query?.continue }); + return { pods: res.items || [] }; + } catch { return { pods: [] }; } + } + + async getPod(args: { param: { namespace: string; podName: string } }): Promise { + return this.getCoreV1Api().readNamespacedPod({ name: args.param.podName, namespace: args.param.namespace }); + } + + async createPod(args: { json: { namespace: string; pod: V1Pod } }): Promise<{ success: boolean; message: string; pod?: V1Pod }> { + try { + const res = await this.getCoreV1Api().createNamespacedPod({ namespace: args.json.namespace, body: args.json.pod }); + return { success: true, message: 'Pod 创建成功', pod: res }; + } catch (e) { return { success: false, message: `创建 Pod 失败: ${e}` }; } + } + + async deletePod(args: { param: { namespace: string; podName: string } }): Promise<{ success: boolean; message: string }> { + try { + await this.getCoreV1Api().deleteNamespacedPod({ name: args.param.podName, namespace: args.param.namespace }); + return { success: true, message: 'Pod 删除成功' }; + } catch (e) { return { success: false, message: `删除 Pod 失败: ${e}` }; } + } + + async getDeployments(args: { param: { namespace: string }; query?: { limit?: number; continue?: string } }): Promise { + try { + const res = await this.getAppsV1Api().listNamespacedDeployment({ namespace: args.param.namespace, limit: args.query?.limit, _continue: args.query?.continue }); + return { items: res.items || [], metadata: res.metadata || {} }; + } catch { return { items: [], metadata: {} }; } + } + + async getDeployment(args: { param: { namespace: string; name: string } }): Promise { + return this.getAppsV1Api().readNamespacedDeployment({ name: args.param.name, namespace: args.param.namespace }); + } + + async createDeployment(args: { json: { namespace: string; deployment: V1Deployment } }): Promise<{ success: boolean; message: string; deployment?: V1Deployment }> { + try { + const res = await this.getAppsV1Api().createNamespacedDeployment({ namespace: args.json.namespace, body: args.json.deployment }); + return { success: true, message: 'Deployment 创建成功', deployment: res }; + } catch (e) { return { success: false, message: `创建 Deployment 失败: ${e}` }; } + } + + async deleteDeployment(args: { param: { namespace: string; name: string } }): Promise<{ success: boolean; message: string }> { + try { + await this.getAppsV1Api().deleteNamespacedDeployment({ name: args.param.name, namespace: args.param.namespace }); + return { success: true, message: 'Deployment 删除成功' }; + } catch (e) { return { success: false, message: `删除 Deployment 失败: ${e}` }; } + } + + async restartDeployment(args: { param: { namespace: string; name: string } }): Promise<{ success: boolean; message: string }> { + try { + const pods = await this.getPods({ param: { namespace: args.param.namespace }, query: { labelSelector: `app=${args.param.name}` } }); + for (const pod of pods.pods) { + if (pod.metadata?.name) await this.deletePod({ param: { namespace: args.param.namespace, podName: pod.metadata.name } }); + } + return { success: true, message: 'Deployment 重启成功' }; + } catch (e) { return { success: false, message: `重启 Deployment 失败: ${e}` }; } + } + + async createService(args: { json: { namespace: string; service: V1Service } }): Promise<{ success: boolean; message: string; service?: V1Service }> { + try { + const res = await this.getCoreV1Api().createNamespacedService({ namespace: args.json.namespace, body: args.json.service }); + return { success: true, message: 'Service 创建成功', service: res }; + } catch (e) { return { success: false, message: `创建 Service 失败: ${e}` }; } + } + + async deleteService(args: { param: { namespace: string; serviceName: string } }): Promise<{ success: boolean; message: string }> { + try { + await this.getCoreV1Api().deleteNamespacedService({ name: args.param.serviceName, namespace: args.param.namespace }); + return { success: true, message: 'Service 删除成功' }; + } catch (e) { return { success: false, message: `删除 Service 失败: ${e}` }; } + } + + async getService(args: { param: { namespace: string; serviceName: string } }): Promise<{ service?: V1Service }> { + try { + const res = await this.getCoreV1Api().readNamespacedService({ name: args.param.serviceName, namespace: args.param.namespace }); + return { service: res }; + } catch { return {}; } + } + + async createIngress(args: { json: { namespace: string; ingress: V1Ingress } }): Promise<{ success: boolean; message: string; ingress?: V1Ingress }> { + try { + const res = await this.getNetworkingV1Api().createNamespacedIngress({ namespace: args.json.namespace, body: args.json.ingress }); + return { success: true, message: 'Ingress 创建成功', ingress: res }; + } catch (e) { return { success: false, message: `创建 Ingress 失败: ${e}` }; } + } + + async deleteIngress(args: { param: { namespace: string; ingressName: string } }): Promise<{ success: boolean; message: string }> { + try { + await this.getNetworkingV1Api().deleteNamespacedIngress({ name: args.param.ingressName, namespace: args.param.namespace }); + return { success: true, message: 'Ingress 删除成功' }; + } catch (e) { return { success: false, message: `删除 Ingress 失败: ${e}` }; } + } + + async getIngress(args: { param: { namespace: string; ingressName: string } }): Promise<{ ingress?: V1Ingress }> { + try { + const res = await this.getNetworkingV1Api().readNamespacedIngress({ name: args.param.ingressName, namespace: args.param.namespace }); + return { ingress: res }; + } catch { return {}; } + } +}