jim800121chen 4d381c0b50 feat(task-scheduler): Phase 1 — modularize server + add OAuth/JWKS + /api/v1/* routes
Refactor server.js (647 → 99 lines) into 30+ modules under src/:
- auth/: JWKS validation, JWT middleware, OAuth client_credentials
- routes/v1/: jobs (POST/GET/:id) + promote with input validation
- routes/legacy.js: existing /jobs multipart path (backward compatible)
- services/: jobService, healthService, sseService, statusMapper,
  doneListener
- middleware/: requestId, errorHandler, perClientRateLimit,
  uploadConcurrency, upload (multer + storage)
- redis/: Lua scripts for atomic claim/release_active_job
- storage/: local + minio adapters; fileAccessAgent/: PUT promote client
- config.js: env var validation with fail-fast

Phase 1 features (T1–T11):
- T1 Auth middleware + JWKS (Member Center OAuth2 resource server)
- T2 OAuth client (Member Center client_credentials, Basic auth)
- T3 /api/v1/* router skeleton
- T4 server.js refactor (legacy endpoints fully preserved, real-Redis
  regression verified — existing worker consumer group untouched)
- T5 POST /api/v1/jobs (multipart, OWASP-audited, 2 Critical / 6 Major
  fixed; Risk-A/B documented as accepted)
- T6 GET /api/v1/jobs + GET /:id (cursor pagination, ETag, IDOR-safe)
- T7 POST /jobs/:id/promote (FAA PUT with own service token, 300s
  timeout, fail-fast on missing FAA URL)
- T8 /health upgrade (healthy/degraded/unhealthy + 30s background cache)
- T9 stage_timings (release_active_job in terminal states)
- T10 env + Docker integration (MULTIPART_* + concurrency limiter)
- T11 README (498 lines) + OpenAPI 3.0 spec (1588 lines)

Tests: 630 pass across 29 suites. Updated Dockerfile + .dockerignore +
docker-compose.yml env passthrough (no hardcoded secrets, fail-fast on
missing required vars).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:55:05 +08:00

499 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Task Scheduler — Kneron Model Converter Phase 1
Kneron Model Converter 的 Job 管理與 queue orchestration 服務。負責接收上游
visionA-backend / Web UI的轉檔請求協調 ONNX → BIE → NEF pipeline並把成功
的結果檔 promote 到 File Access Agent / NAS 模型庫。
> **Phase 1 對外 API 完整規格** → 見 `docs/openapi.yaml`
---
## 1. 專案介紹
### 1.1 服務角色
```
public Internet internal
visionA-backend ─→ Nginx (443, public vhost) ─→ /api/v1/* ─→ task-scheduler ─→ Worker
Web UI ─→ Nginx (80, internal vhost) ─→ /jobs ──┘ │
ONNX → BIE → NEF
MinIO Bucket
POST /api/v1/jobs/:id/promote
File Access Agent
NAS 模型庫
```
task-scheduler 是 Phase 1 唯一暴露給上游的應用層元件,承擔:
- 對外 API**Phase 1 新增**`/api/v1/*` 共 4 個端點 + 2 個 Phase 2 預留
- 內部 API**保留既有**`/jobs/*` 共 6 個 legacy 端點Web UI 用)
- 健康檢查:`/health`(公開)
### 1.2 技術堆疊
| 層級 | 技術 | 版本 |
|------|------|------|
| 執行環境 | Node.js | 18+ (alpine image, 部署用) |
| Web framework | Express | 4.x |
| Queue | Redis Stream + ioredis | 5.x |
| 物件儲存 | MinIOS3 compatibleAWS SDK v3 | latest |
| 認證 | OAuth 2.0 + JWTjose | jose 5.x |
| 上傳 | multer (memoryStorage) | 1.4.x |
| 速率限制 | express-rate-limit | 6.x |
| 安全 headers | helmet | 7.x |
| 測試 | Jest | 29.x |
---
## 2. 前置需求
| 項目 | 版本 / 說明 |
|------|-----------|
| Node.js | 18+fetch 原生支援、`duplex: 'half'` |
| npm | 9+ |
| Docker / docker-compose可選 | 24.x+ |
| Redis | 7.xdev / prod 都需要) |
| MinIO | latestPOST /api/v1/jobs 必須啟用) |
| Member Center | OAuth 2.0 Authorization Server提供 JWKS / token endpoint |
| File Access Agent | promote 階段呼叫,需支援 `PUT /files/{key}` |
dev 環境若無真實 Member Center / FAA可用 placeholder 值(見 `env.example`)。
---
## 3. 啟動方式
### 3.1 本機開發(純 Node
```bash
cd apps/task-scheduler
cp env.example .env
# 編輯 .env至少把以下 placeholder 替換為真實值:
# - MEMBER_CENTER_*(若要實際打 Member Center
# - KNERON_CONVERTER_CLIENT_SECRET
# - MINIO_*(若 STORAGE_BACKEND=minio
npm install
npm start
# → 監聽 PORT預設 4000
```
### 3.2 Docker 單體
```bash
docker build -t task-scheduler:dev apps/task-scheduler
docker run --rm --env-file apps/task-scheduler/.env -p 4000:4000 task-scheduler:dev
```
### 3.3 docker-compose推薦
專案根目錄已有 `docker-compose.yml`,會一併啟動 Redis、MinIO、Workers、frontend
```bash
cd /path/to/kneron_model_converter
cp apps/task-scheduler/env.example .env # 或維護一份 root .env
docker compose up -d --build
```
服務埠對外:
- Scheduler API`http://localhost:4000`
- Web UI`http://localhost:3000`
- MinIO Console`http://localhost:9001`
### 3.4 Health check
```bash
curl http://localhost:4000/health | jq .
```
回應為三層 statushealthy / degraded / unhealthy+ 各依賴狀態,
詳見 [§ 7. 監控](#7-監控)。
### 3.5 Graceful shutdown
服務監聽 `SIGTERM` / `SIGINT`:收到後會先停掉 health background polling
再讓 Express 自然關閉。容器 / K8s 部署時 `terminationGracePeriodSeconds`
建議至少 30 秒。
---
## 4. 專案結構
```
apps/task-scheduler/
├── server.js ← entry< 140 行;組裝 deps、啟動 listener、listen
├── src/
│ ├── app.js ← Express app factory
│ ├── config.js ← 集中讀 env啟動時 fail-fast
│ ├── redis.js ← Redis client + helpers
│ ├── auth/
│ │ ├── jwks.js ← jose remote JWKS cache + jwtVerify
│ │ ├── middleware.js ← requireAuth(scope) Express middleware
│ │ └── oauthClient.js ← Converter as OAuth Clientclient_credentials
│ ├── fileAccessAgent/
│ │ ├── client.js ← FAA HTTP clientPUT only重試 + 401 invalidate
│ │ └── errors.js
│ ├── middleware/
│ │ ├── errorHandler.js ← 統一 error 格式v1 限定)
│ │ ├── requestId.js ← X-Request-Id 透傳 / 生成
│ │ ├── perClientRateLimit.js ← per-client_id rate limiter
│ │ ├── upload.js ← multer 設定
│ │ └── uploadConcurrency.js ← per-process upload semaphore防 OOM
│ ├── routes/
│ │ ├── legacy.js ← /jobs* 6 個端點Web UI 用)
│ │ └── v1/
│ │ ├── index.js ← /api/v1 mount + 內部 errorHandler
│ │ ├── jobs.js ← POST/GET /jobs, GET /jobs/:id, 預留 501
│ │ ├── promote.js ← POST /jobs/:id/promote
│ │ └── validators/
│ │ └── createJob.js ← multipart fields validator
│ ├── services/
│ │ ├── jobService.js ← Job CRUD + claim_active / advance / fail
│ │ ├── doneListener.js ← Redis Stream 背景 listener
│ │ ├── healthService.js ← /health 背景 polling cache
│ │ ├── statusMapper.js ← 內部大寫 status → 對外 status + stage
│ │ └── sseService.js ← SSE 推送legacy
│ ├── storage/
│ │ ├── minio.js ← AWS SDK v3 S3 facade
│ │ └── local.js ← STORAGE_BACKEND=local 模式
│ ├── redis/
│ │ └── luaScripts.js ← claim_active_job / release_active_job
│ └── utils/
│ └── sanitize.js ← filename / user_id / path 安全處理
├── docs/
│ └── openapi.yaml ← Phase 1 對外 API spec給 visionA 等消費者)
├── tests/ ← 單元 + 整合測試(見 src/**/__tests__/
├── package.json
├── Dockerfile ← 多層快取 + 非 root user + HEALTHCHECK
├── env.example ← 完整環境變數範本(不含真實 secret
└── README.md ← 本檔
```
---
## 5. 環境變數
完整清單(含預設、必填與否、說明)見 [`env.example`](./env.example)。
簡表(依分類):
### 5.1 必填(缺漏會 fail-fast、process exit code 1
| 變數 | 用途 |
|------|------|
| `REDIS_URL` | Redis 連線(含 password |
| `STORAGE_BACKEND` | `local` / `minio`POST /api/v1/jobs 必須 `minio` |
| `MEMBER_CENTER_ISSUER` | JWT iss 比對基準 |
| `MEMBER_CENTER_JWKS_URL` | JWKS endpoint驗 token 用) |
| `MEMBER_CENTER_TOKEN_URL` | token endpoint取 promote 用 token |
| `KNERON_CONVERTER_AUDIENCE` | 接受 JWT 的 aud |
| `KNERON_CONVERTER_CLIENT_ID` | Converter 自己 OAuth client |
| `KNERON_CONVERTER_CLIENT_SECRET` | **不要進 git用 secret manager** |
| `FILE_ACCESS_AGENT_BASE_URL` | promote 目標production 強制 https |
| `FILE_ACCESS_AGENT_AUDIENCE` | promote token 的 aud |
`STORAGE_BACKEND=minio` 時還需:`MINIO_ENDPOINT_URL` / `MINIO_BUCKET` /
`MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY`
### 5.2 可選(有合理預設)
涵蓋:
- 上傳上限(`MULTIPART_MODEL_MAX_BYTES` 預設 500MB、`MULTIPART_REF_IMAGE_MAX_BYTES`
預設 10MB、`MULTIPART_REF_IMAGES_MAX_COUNT` 預設 100
- 上傳並發(`MAX_CONCURRENT_UPLOADS` 預設 5、`UPLOAD_RETRY_AFTER_SECONDS` 預設 30
- Rate limit`API_V1_RATE_LIMIT_WINDOW_MS` 預設 5min、`API_V1_RATE_LIMIT_MAX` 預設 300
- JWKS 行為(`JWKS_CACHE_MAX_AGE_MS``JWKS_COOLDOWN_MS``JWT_CLOCK_TOLERANCE_SEC`
- OAuth client`OAUTH_TOKEN_REFRESH_SKEW_MS``OAUTH_TOKEN_TIMEOUT_MS`
- promote timeout`PROMOTE_TIMEOUT_MS` 預設 300s
- Tenant 隔離(`CONVERTER_TENANT_ID`,空字串 = 不檢查)
- Scope 命名覆寫(`CONVERTER_SCOPE_WRITE` / `CONVERTER_SCOPE_READ`
### 5.3 安全提醒
- `.env` 已在 `.gitignore`;不要 commit
- production 用 secret managerVault / AWS Secrets Manager / K8s Secret
而不是把 secret 直接放進 docker-compose env
- 任何含 `REPLACE-ME` 字樣或 `.invalid` TLD 的 placeholder**部署前必須替換**
---
## 6. API 概覽
### 6.1 Phase 1 對外 API`/api/v1/*`
| 方法 | 路徑 | scope | 說明 |
|------|------|-------|------|
| POST | `/api/v1/jobs` | `converter:job.write` | 建立轉檔 jobmultipart |
| GET | `/api/v1/jobs` | `converter:job.read` | Recovery 列表user_id 必填) |
| GET | `/api/v1/jobs/:id` | `converter:job.read` | 單一 job 狀態(含 ETag |
| POST | `/api/v1/jobs/:id/promote` | `converter:job.write` | 結果檔搬到 FAA |
| POST | `/api/v1/jobs/:id/download-tokens` | `converter:job.read` | **Phase 2 預留**,回 501 |
| DELETE | `/api/v1/jobs/:id` | `converter:job.write` | **Phase 2 預留**,回 501 |
完整規格、所有 schema、所有錯誤情境的 example見 [`docs/openapi.yaml`](./docs/openapi.yaml)。
### 6.2 Legacy / 內部 API`/jobs/*`,僅內網 vhost 暴露)
對 Web UI 100% 不變更行為T4 重構僅是「移動 + 抽象」):
| 方法 | 路徑 | 說明 |
|------|------|------|
| POST | `/jobs` | Web UI 上傳建 jobmultipart無 user_id 概念) |
| GET | `/jobs` | 列出全部 joblegacy KEYS scan |
| GET | `/jobs/:jobId` | 查單一 job |
| GET | `/jobs/:jobId/events` | SSE 推送 |
| GET | `/jobs/:jobId/download/:filename` | 下載結果檔 |
| GET | `/queues/stats` | Redis Stream / Group 統計 |
### 6.3 健康檢查
| 方法 | 路徑 | 說明 |
|------|------|------|
| GET | `/health` | 公開,不需認證 |
---
## 7. Auth 流程
### 7.1 上游消費者visionA-backend取 token
Converter 是 OAuth 2.0 Resource Server。建議消費者用 `client_credentials`
grant 從 Member Center 取得 service-to-service token
```
POST {member-center}/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=<your-client>
&client_secret=<your-secret>
&scope=converter:job.write converter:job.read
&audience=kneron_converter_api
```
### 7.2 Converter 端驗證
每個 `/api/v1/*` request 進入時:
1. Bearer token 驗章(`jose.createRemoteJWKSet` + `jwtVerify`
2. `iss` / `aud` / `exp`(含 60 秒 clock skew
3. `scope`(端點要求的 scope 必須在 token claim 內)
4. `tenant_id`(若 `CONVERTER_TENANT_ID` 非空則檢查)
5. `client_id`(用於 rate limit / log / job 隔離)
驗證失敗時:
- 回 v1 標準錯誤格式(`{error: {code, message, details, request_id}}`
- **設 `Connection: close` header + `req.socket.destroy()`**:阻止
unauthorized client 繼續灌大檔。但這是 best-effort真正的 body 上限
靠 Nginx `client_max_body_size`(部署層)
### 7.3 Converter 取 promote 用 token
promote 時 Converter 切換成 OAuth Client`client_credentials`
`files:upload.write` scope tokenPUT 到 FAA。
token cache per scope過期前 60s 主動 refreshFAA 回 401 時自動
invalidate cache 並重試一次。
---
## 8. 錯誤碼總表
| HTTP | code | 說明 |
|------|------|------|
| 400 | `validation_error` | 欄位格式錯(`details.fields[]` 列具體欄位) |
| 400 | `invalid_multipart` | multipart parse 失敗、缺必要 file、副檔名不符 |
| 401 | `invalid_token` | JWT 無效 / 簽章錯 / 缺 claim |
| 401 | `token_expired` | JWT 過期 |
| 403 | `insufficient_scope` | scope 不足(`details.required_scope` / `provided_scopes` |
| 403 | `tenant_mismatch` | tenant_id 不符 |
| 404 | `job_not_found` | job 不存在或不屬於該 client不洩漏存在性 |
| 404 | `not_found` | 路徑不存在 |
| 409 | `user_has_active_job` | 同 user 已有未完成 job`details.active_job_*` |
| 409 | `job_not_ready_for_promote` | promote 時 job 非 completed |
| 409 | `source_not_available` | promote 的 source stage 沒產出 |
| 413 | `file_too_large` | 上傳超過大小上限model 500MB / ref_image 10MB |
| 422 | `invalid_object_key` | promote target_object_key 格式不合法 |
| 429 | `rate_limit_exceeded` | per-client rate limit |
| 500 | `misconfiguration` | 伺服器設定錯(如 STORAGE_BACKEND 非 minio |
| 500 | `internal_error` | 其他未分類錯誤 |
| 501 | `not_implemented` | Phase 2 預留端點 |
| 502 | `storage_unavailable` | MinIO 寫入失敗 |
| 502 | `file_gateway_unavailable` | FAA 不可用 / 拒絕 |
| 503 | `auth_service_unavailable` | Member Center 取 token 失敗 |
| 503 | `service_busy` | upload concurrency 已滿(`Retry-After` header |
response 完整 schema 見 [`docs/openapi.yaml`](./docs/openapi.yaml#components/schemas/ApiError)。
---
## 9. 與其他服務的關係
| 服務 | 連接方式 | 用途 | 失敗影響 |
|------|---------|------|---------|
| Member Center | HTTPS | 驗 visionA token / 取 promote token | 新 token 無法驗cache 內舊 token 仍可用promote 階段失敗 |
| File Access Agent | HTTPS | promote 結果檔搬到 NAS | promote 失敗,但 job 本身已 completed可重試 |
| MinIO | HTTP / HTTPS | 原始模型 / 結果檔暫存7 天 lifecycle | POST /jobs 直接 502promote 也會失敗 |
| Redis | TCP | Job state、active_job lock、Stream queue | 整個服務 unhealthy |
| Workeronnx / bie / nef | Redis Stream | 跑 pipeline | Job 卡在某個 stageTTL 7 天會自動清 |
---
## 10. 監控
### 10.1 `/health` 的三層 status
| status | HTTP | 對應狀態 |
|--------|------|---------|
| `healthy` | 200 | Redis / MC / FAA 都連通 |
| `degraded` | 200 | Redis 連通,但 MC / FAA 任一不可達 |
| `unhealthy` | 503 | Redis 斷線 |
response body 同時包含 `dependencies.{redis, member_center, file_access_agent}`
細節,可給 K8s readiness / liveness probe 區分嚴重度。
### 10.2 結構化日誌
所有 v1 路徑的 handler 都輸出 JSON logstdout
```json
{
"service": "task-scheduler",
"timestamp": "2026-04-25T12:00:00.123Z",
"level": "INFO",
"action": "jobs.create.success",
"request_id": "7c6e4f3b-...",
"job_id": "550e8400-...",
"user_id": "alice",
"client_id": "kneron_converter_dev",
"size_bytes": 204800000,
"ref_images_count": 0,
"duration_ms": 234
}
```
`action` 欄位採 `domain.event` 格式,便於用 jq / loki 過濾。
### 10.3 Rate limit headers
回應自動帶:
- `X-RateLimit-Limit` / `RateLimit-Limit`
- `X-RateLimit-Remaining` / `RateLimit-Remaining`
- 超限時:`Retry-After`(秒)
---
## 11. Phase 1 已知接受風險
> 本節為摘要,完整內容見 [`.autoflow/04-architecture/security.md`](../../.autoflow/04-architecture/security.md)。
### 11.1 user_id 信任邊界(最重要)
- `user_id` 來自 multipart form fieldPOST或 query stringGET
**不**從 JWT claim derive
- Converter 完全信任 visionA-backend 帶來的 user_id 是對的,**不做 user 層級 ACL**
- visionA-backend 一旦被 compromiseattacker 可冒充任何 user_id
**Phase 1 接受此風險的理由**
1. visionA-backend 是內部受控系統,非 Internet-facing
2. Phase 1 重點是 pipeline 跑通;安全強化排在 Phase 2
3. HMAC / OBO 流程要 visionA / Member Center 配合,已對齊但尚未實作
**Phase 1 mitigation**
- per-client_id rate limit300 req / 5 min
- 結構化 audit log 含 `client_id` + `user_id`
- 7 天 active_job TTL避免 lock 永久不釋放)
- `user_id` 嚴格白名單(`^[A-Za-z0-9._-]{1,128}$`)擋 XSS / Redis key injection
**Phase 2 候補**HMAC-signed user_id短期/ OAuth Token Exchange中期
### 11.2 大檔上傳的 OOM 風險
- multer 用 `memoryStorage` — 每個並發 upload 吃 model size 大小的 heap
- 5 並發 × 500MB = 2.5GB`MAX_CONCURRENT_UPLOADS` 預設 54GB 容器安全)
- 超過時 503 + `Retry-After`client 主動 backoff
### 11.3 Trust boundary 與 Nginx 層
- 401/403 後 server 雖會 `socket.destroy()`,但這是 best-effort
- 真正的 body 大小上限由 Nginx vhost `client_max_body_size 600M` 把關
- Nginx 雙 vhost 設定詳見 TDD §7.1DevOps 範圍,非後端)
### 11.4 Per-process statePhase 2 才需處理)
- rate limiter / upload concurrency 都是 in-process counter
- Phase 1 部署為單 instance無問題Phase 2 多 instance 時要改 Redis store
---
## 12. 測試
```bash
npm test # 跑所有 unit + integration test630 tests~4 秒)
npm test -- --watch # watch 模式
npm test -- src/auth # 只跑 auth 模組的測試
```
測試金字塔:
- 單元測試70%service / validator / utils / middleware
- 整合測試20%route + middleware + Redis 模擬 / FAA mock
- E2E10%):由 Testing Agent 跑(不在本套件內)
CI 用:`npm test`
---
## 13. 故障排除(常見場景)
| 症狀 | 可能原因 | 排查 |
|------|---------|------|
| 啟動立刻 exit 1 | env 缺漏 | 看 `[Scheduler] Config validation failed` log對照 `env.example` |
| 401 invalid_token / token_expired | clock skew、JWKS cache 沒拿到新 kid | 檢查 server 時鐘、`MEMBER_CENTER_JWKS_URL` 可達性 |
| 401 後 client 連線立刻斷 | 設計如此(`Connection: close` + `socket.destroy()` | 正常行為,避免 client 繼續灌 body |
| 409 user_has_active_job 但前一個 job 已 failed | active_job lock 沒被釋放 | 看 worker done listener 是否運作;最壞情況 7 天 TTL 會自動清 |
| 502 storage_unavailable | MinIO 不可達 / 認證錯 | 檢查 `MINIO_*` env、bucket 是否存在 |
| 502 file_gateway_unavailable | FAA 5xx 或 4xx 拒絕(非 401 | 看 server log `promote.faa_put_failed`FAA 端排查 |
| 503 auth_service_unavailable | Member Center token endpoint 死 / 401 兩次 | 確認 `MEMBER_CENTER_TOKEN_URL` 可達、`KNERON_CONVERTER_CLIENT_*` 對 |
| 503 service_busy + Retry-After | upload concurrency 已滿 | 等 Retry-After或調高 `MAX_CONCURRENT_UPLOADS`(注意 OOM |
| 503 unhealthy/health | Redis 斷線 | 檢查 `REDIS_URL` 與 Redis 服務狀態 |
| GET /jobs 回 400 missing user_id | Phase 1 強制 user_id 必填 | client 端帶 user_id query string |
| 大檔上傳跑到一半 5xx | Nginx `client_max_body_size` 太小 | 部署層調 `client_max_body_size 600M`(不在 backend 範圍) |
更多細節:
- `.autoflow/04-architecture/TDD.md`(完整規格)
- `.autoflow/04-architecture/security.md`(安全模型 / 接受風險)
- `.autoflow/05-implementation/tasks-phase1.md`(任務拆分與決策紀錄)
---
## 14. 文件參照
| 文件 | 內容 |
|------|------|
| [`docs/openapi.yaml`](./docs/openapi.yaml) | Phase 1 對外 API spec給 visionA-backend 等消費者 import |
| [`env.example`](./env.example) | 完整環境變數清單(含說明、預設、必填與否) |
| `../../.autoflow/04-architecture/TDD.md` | 完整技術設計文件 |
| `../../.autoflow/04-architecture/security.md` | 安全模型 / 接受風險 / Phase 2 候補 |
| `../../.autoflow/04-architecture/design-doc.md` | 架構決策(為什麼選這些方案) |
| `../../.autoflow/02-prd/PRD.md` | 產品需求 / user stories |
| `../../.autoflow/05-implementation/tasks-phase1.md` | T1-T11 任務拆分與審查紀錄 |
---
## 15. License
MIT