對齊 ADR-015:visionA backend 從 OAuth client_credentials 改 pre-shared API key 服務間認證。Phase 0.8 stage e2e 撞 4 個 blocker(MC scope 沒註冊 / converter image 舊版 / converter 缺 env / FAA 不確定)後,使用者拍板 1:1 internal trust 用 OAuth 過度設計,改 API key。
實作(5 個增量 task,T1-T5 全綠 + Reviewer 5 輪 + final cross-task review):
T1 config + env:
- ConversionConfig 新增 ConverterAPIKey / FAAAPIKey 欄位
- Enabled() 改判定 4 欄位齊全(含兩個 API key)
- .env*.example 移除 OIDC service client / OIDC tenant / FAA delegated TTL env、新增 API key env
- TenantID / DelegatedTTLSeconds T1 暫留、T5 整批清
T2 client 改造:
- converter_client / faa_client 移除 MCTokenClient 依賴
- 直接讀 cfg.Conversion.{Converter,FAA}APIKey、set Authorization: Bearer <key>
- NewConverterClient / NewFAAClient APIKey 為空時 panic(fail-fast,對齊 ADR-015 §3.5.3 #1)
- 新增 ErrConverterAuthFailed / ErrFAAAuthFailed sentinel
- 對外 mask 成 converter_unavailable / faa_unavailable(不洩漏 401 細節,對齊 ADR-015 §3.5.3 #3)
T3 砍 mc_token_client:
- mc_token_client.go (624 行) + mc_token_client_test.go (864 行) 整檔砍
- 砍 5 個僅 mc_token_client 用的 sentinel(ErrServiceClientUnauthorized / ErrMCTokenUnavailable / ErrIDPMisconfigured / ErrIDPUnavailable / ErrDownloadTokenFailed)
- helper(truncate / silentLogger)搬到 util.go / testing_helpers_test.go
T4 flow + handler stream proxy:
- Service interface DownloadRedirectURL → DownloadStream(ctx) (io.ReadCloser, *DownloadMetadata, error)
- flow.DownloadStream 用 faa.GetFile 直接 stream NEF binary(取代 MC delegated token + 302)
- handler conversionDownloadHandler 改 io.Copy + Content-Type/Disposition/Cache-Control header
- 新增 sanitizeDownloadFilename helper 防 HTTP header injection
- 跨 package handler test (conversion_test.go) 改測 ErrFAAUnavailable + 補 *_AuthFailed 對稱 test
T5 wire 切換 + cleanup:
- main.go 砍 mcTokenClient wire、改 APIKey 注入、startup log 用 *_api_key_set boolean(不印 key)
- ConverterClient/FAAClient struct Tokens 欄位移除
- mc_token_stub.go (T4 過渡期) 整檔砍
- ConversionConfig TenantID / DelegatedTTLSeconds 欄位移除
- e2e_test.go TestConversionE2E_Download302Redirect 改寫為 TestConversionE2E_DownloadStream
- 補 MaxDownloadStreamBytes = 1 GiB size cap(io.CopyN,T4 reviewer Minor M-1)
- escapeObjectKeyPath Phase 1+ 預留 godoc + //nolint:unused(T4 reviewer Minor M-2)
- conversion.md §4.1 line 502 filename 來源描述歧義修訂(T4 reviewer Minor M-3,由 architect 處理)
- conversion_e2e_test.go 檔頭 docstring 更新(final reviewer Minor #1)
驗證:
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- ADR-015 §6 砍除清單 100% cover(reviewer 獨立 grep 確認 MC chain / TenantID / DelegatedTTLSeconds 全清)
- ADR-015 §3.5.3 部署檢查清單 visionA 範圍 4/4 達成(fail-fast / mask / 不印 token / placeholder env)
不動:
- OIDCConfig.ServiceClientID/Secret 欄位保留(使用者拍板 backward compat)
- user login OIDC 完全不動
下一步:
- 步驟 4 — converter scheduler middleware 改 API key(jimchen 跨 repo,ADR-015 §3.5.1 Go snippet)
- 步驟 5 — FAA middleware 改 API key(warrenchen 跨 repo,ADR-015 §3.5.2 C# snippet)
- 步驟 6 — stage redeploy + e2e 完整測試
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
284 lines
13 KiB
Go
284 lines
13 KiB
Go
// Package conversion error 定義。
|
||
//
|
||
// 對齊 conversion.md §6 錯誤碼 mapping 與 api-conversion.md §錯誤碼總覽。
|
||
//
|
||
// 設計原則:
|
||
// - 用 sentinel error(package-level var)+ wrap 模式,不用 error code string
|
||
// 做 equality check(caller 用 errors.Is 判斷)
|
||
// - 每個 sentinel 都對應一個對外 error code(見 ErrorCode() helper)
|
||
// - HTTP status mapping 與 message 在 handler 層處理(見 internal/api/conversion.go),
|
||
// 避免 conversion package 依賴 gin
|
||
//
|
||
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6)
|
||
package conversion
|
||
|
||
import "errors"
|
||
|
||
// Sentinel errors — handler 層用 errors.Is 比對。
|
||
var (
|
||
// ErrForbidden — job 不屬於當前 user。對應 HTTP 403 / code "forbidden"。
|
||
ErrForbidden = errors.New("conversion: forbidden")
|
||
|
||
// ErrJobNotFound — job_id 不存在或已過期。對應 HTTP 404 / code "not_found"。
|
||
ErrJobNotFound = errors.New("conversion: job not found")
|
||
|
||
// ErrJobNotCompleted — job 還沒 completed,不能 promote / download。
|
||
// 對應 HTTP 409 / code "job_not_completed"。
|
||
ErrJobNotCompleted = errors.New("conversion: job not completed")
|
||
|
||
// ErrActiveJobExists — 同 user 已有 active job。
|
||
// 對應 HTTP 409 / code "active_job_exists"。
|
||
// caller 可用 ActiveJobError struct 取得衝突中的 job 資訊(見下方)。
|
||
ErrActiveJobExists = errors.New("conversion: user already has active job")
|
||
|
||
// ErrValidationFailed — 上傳的 multipart 內容格式錯誤(converter 4xx validation_error / invalid_multipart)。
|
||
// 對應 HTTP 400 / code "validation_failed"。
|
||
ErrValidationFailed = errors.New("conversion: validation failed")
|
||
|
||
// ErrPayloadTooLarge — converter 端拒絕超大檔案。
|
||
// 對應 HTTP 413 / code "payload_too_large"。
|
||
ErrPayloadTooLarge = errors.New("conversion: payload too large")
|
||
|
||
// ErrConverterUnavailable — converter 5xx / network 持續失敗。
|
||
// 對應 HTTP 502 / code "converter_unavailable"。
|
||
ErrConverterUnavailable = errors.New("conversion: converter unavailable")
|
||
|
||
// ErrFAAUnavailable — FAA 5xx / network 持續失敗。
|
||
// 對應 HTTP 502 / code "faa_unavailable"。
|
||
ErrFAAUnavailable = errors.New("conversion: faa unavailable")
|
||
|
||
// ErrFAAFileNotFound — FAA 回 404(指定 object_key 不存在)。
|
||
// 觸發情境:promote-to-models 流程 promoted 後 FAA pull 卻找不到檔(罕見:
|
||
// converter promote 才剛寫 FAA、應立即可見)— 可能 FAA 端 GC、或 object_key 命名邏輯有 bug。
|
||
// 對應 HTTP 502 / code "faa_unavailable"(對外仍視為 FAA 不可用,避免揭露內部 object key 細節)。
|
||
// caller(flow.go)可用 errors.Is(err, ErrFAAFileNotFound) 做精細處理(log / metric)。
|
||
//
|
||
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.6 + §9.2)
|
||
ErrFAAFileNotFound = errors.New("conversion: faa file not found")
|
||
|
||
// ErrServiceBusy — converter 端回 503 service_busy。
|
||
// 對應 HTTP 503 / code "service_busy"。
|
||
ErrServiceBusy = errors.New("conversion: service busy")
|
||
|
||
// Phase 0.8b T3 移除(5 個僅 mc_token_client 用的 sentinel,MC 路徑取消後不再有觸發點):
|
||
// - ErrDownloadTokenFailed — MC delegated token 4xx
|
||
// - ErrMCTokenUnavailable — MC 5xx / network 持續失敗
|
||
// - ErrIDPMisconfigured — MC token endpoint 4xx(client_credentials grant 設定錯誤)
|
||
// - ErrIDPUnavailable — MC oauth/token 5xx / network 持續失敗
|
||
// - ErrServiceClientUnauthorized — visionA → MC 認證失敗(401/403)
|
||
// 取代:401/403 改 mapping 到 ErrConverterAuthFailed / ErrFAAAuthFailed(下方);
|
||
// converter 端 503 改 mapping 到 ErrConverterUnavailable(converter_client.go mapPromoteError)。
|
||
//
|
||
// **不重用舊 sentinel name**(Phase 1+ 注意):上述 5 個 sentinel name 已從 git history
|
||
// 中砍除,未來若需要新增 sentinel 不應重用同名(如 `ErrIDPUnavailable`),以免閱讀
|
||
// git log / blame / 既有 reference 時混淆語意(同名但對應不同層的失敗模式)。
|
||
// 若未來 MC 認證鏈以新樣態回來(例如 ADR-015 §7 選項 B 的「visionA 自簽 HMAC token」),
|
||
// 採用新 sentinel name(例:`ErrHMACTokenUnavailable` / `ErrHMACSigningFailed`)。
|
||
// 對應 T3 Reviewer Minor #M-3 / T4 補註說明(.autoflow/05-implementation/backend/logs/t4-final-*.log)
|
||
|
||
// ErrConverterAuthFailed — visionA-backend → converter 帶的 API key 不對齊
|
||
// (converter middleware constant-time compare 失敗 → 401 / 403)。
|
||
//
|
||
// 觸發情境(Phase 0.8b API key 路徑):
|
||
// - VISIONA_CONVERTER_API_KEY 與 converter 端 CONVERTER_API_KEY 不同步
|
||
// (rotate 後一邊還沒換 env、stage / prod env 設錯)
|
||
// - converter middleware 上線前 visionA 過早部署(converter 還沒驗 key)
|
||
//
|
||
// 設計選擇:對外仍 mask 成 converter_unavailable / 502 — 不洩漏「API key 對 / 不對」
|
||
// 這個內部運維狀態給 frontend;SRE 從 server log 看到 auth_failed 計數異常 → 檢查 env。
|
||
// 與 ErrConverterUnavailable 分開的 sentinel 是為了 log / metric 分桶(運維事件 vs 上游不可達),
|
||
// 對外的 user-facing message 仍然一樣(避免 social engineering 利用 401 訊號做攻擊)。
|
||
//
|
||
// Phase 0.8b conversion (見 ADR-015 §6 / conversion.md §6 / api-conversion.md)
|
||
ErrConverterAuthFailed = errors.New("conversion: converter api key auth failed")
|
||
|
||
// ErrFAAAuthFailed — visionA-backend → FAA 帶的 API key 不對齊(FAA middleware 401 / 403)。
|
||
//
|
||
// 觸發情境(Phase 0.8b API key 路徑):
|
||
// - VISIONA_FAA_API_KEY 與 FAA 端 FAA_API_KEY 不同步(warrenchen 跨 repo 維護)
|
||
// - FAA middleware 上線前 visionA 過早部署
|
||
//
|
||
// 設計選擇:與 ErrConverterAuthFailed 對稱、與 ErrFAAUnavailable 分開(同樣 mask 成
|
||
// faa_unavailable / 502 對外、區分只在 server log)。
|
||
//
|
||
// Phase 0.8b conversion (見 ADR-015 §6 / conversion.md §6 / api-conversion.md)
|
||
ErrFAAAuthFailed = errors.New("conversion: faa api key auth failed")
|
||
|
||
// ErrStorageUnavailable — visionA 自家 storage(local FS / S3)寫入或讀取失敗。
|
||
//
|
||
// 觸發情境:
|
||
// - PromoteToModels 把 NEF 寫進 visionA storage 失敗(disk full / S3 5xx / 權限錯誤)
|
||
// - 與 FAA / converter 都無關,純粹是 visionA 自己的 storage 設定問題
|
||
//
|
||
// 對應 HTTP 500 / code "storage_unavailable"。
|
||
//
|
||
// 設計選擇(與 ErrFAAUnavailable 區分):
|
||
// - storage 失敗 ≠ FAA 失敗。SRE alarm 會打到不同 team;i18n 訊息也不同
|
||
// (FAA 對外是 "檔案存取服務暫時無法使用",storage 對外是 "伺服器內部錯誤")
|
||
// - 對外用 500 而非 502:visionA 自身問題,不是 gateway / upstream 問題
|
||
//
|
||
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6 — Reviewer M-1)
|
||
ErrStorageUnavailable = errors.New("conversion: visionA storage unavailable")
|
||
|
||
// ErrModelStoreUnavailable — visionA 自家 model store(in-memory / Postgres)操作失敗。
|
||
//
|
||
// 觸發情境:
|
||
// - PromoteToModels 把 model record 寫進 model store 失敗
|
||
// (in-memory 永遠不會失敗;未來換 Postgres 時 connection 5xx 才會觸發)
|
||
// - 與 FAA / converter 都無關,純粹是 visionA 自己的 DB 問題
|
||
//
|
||
// 對應 HTTP 500 / code "model_store_unavailable"。
|
||
//
|
||
// 設計選擇(與 ErrConverterUnavailable 區分):理由同 ErrStorageUnavailable。
|
||
//
|
||
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6 — Reviewer M-1)
|
||
ErrModelStoreUnavailable = errors.New("conversion: visionA model store unavailable")
|
||
)
|
||
|
||
// ActiveJobError 是 ErrActiveJobExists 的 wrapped form,
|
||
// 帶上正在進行中的 job 資訊,給 handler 透傳給 frontend
|
||
// (前端可顯示「你已有進行中任務(job_id=xxx)」+ 跳轉到該 job 的進度頁)。
|
||
//
|
||
// 用法:
|
||
//
|
||
// if errors.Is(err, conversion.ErrActiveJobExists) {
|
||
// var ae *conversion.ActiveJobError
|
||
// if errors.As(err, &ae) {
|
||
// // ae.Job 可用,details 帶給 frontend
|
||
// }
|
||
// }
|
||
type ActiveJobError struct {
|
||
Job *Job
|
||
}
|
||
|
||
// Error 實作 error interface。
|
||
func (e *ActiveJobError) Error() string {
|
||
return ErrActiveJobExists.Error()
|
||
}
|
||
|
||
// Unwrap 讓 errors.Is(err, ErrActiveJobExists) 成立。
|
||
func (e *ActiveJobError) Unwrap() error {
|
||
return ErrActiveJobExists
|
||
}
|
||
|
||
// ValidationFieldError 是 converter 4xx response 中 details.fields 陣列的單一元素。
|
||
//
|
||
// 對齊 converter openapi.yaml `validation_error` example:
|
||
//
|
||
// details.fields: [{ field: "model_id", message: "model_id 範圍必須在 1 ~ 65535" }]
|
||
//
|
||
// 之所以用 array 不用 map:
|
||
// - 對齊 task-scheduler openapi.yaml(POST /api/v1/jobs 400 validation_error 範例)
|
||
// - 同一個 field 可能有多個錯誤(例如 model_id 同時違反 pattern + range)
|
||
//
|
||
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6 + api-conversion.md §1)
|
||
type ValidationFieldError struct {
|
||
Field string `json:"field"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
// ConverterValidationError 是 ErrValidationFailed 的 wrapped form,
|
||
// 帶上 converter 回的欄位錯誤細節(給 frontend 顯示具體哪個欄位錯)。
|
||
//
|
||
// 用法同 ActiveJobError。
|
||
//
|
||
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6 + api-conversion.md §1)
|
||
type ConverterValidationError struct {
|
||
// Fields 是 converter 4xx response 的 details.fields(若有)。
|
||
// 結構對齊 converter openapi.yaml — array of {field, message}。
|
||
// converter 4xx 沒有 details.fields 時為 nil(仍視為 validation 錯誤)。
|
||
Fields []ValidationFieldError
|
||
|
||
// Message 是 converter error message 原文(不過 frontend,僅供 log)。
|
||
Message string
|
||
}
|
||
|
||
// Error 實作 error interface。
|
||
func (e *ConverterValidationError) Error() string {
|
||
if e.Message != "" {
|
||
return "conversion: validation failed: " + e.Message
|
||
}
|
||
return ErrValidationFailed.Error()
|
||
}
|
||
|
||
// Unwrap 讓 errors.Is(err, ErrValidationFailed) 成立。
|
||
func (e *ConverterValidationError) Unwrap() error {
|
||
return ErrValidationFailed
|
||
}
|
||
|
||
// ErrorCode 把 sentinel error 轉成對外的 visionA error code(對齊 api-conversion.md §錯誤碼總覽)。
|
||
//
|
||
// 未匹配的 error 回 "internal_error"(handler 層應 log 完整 error 後回 500)。
|
||
func ErrorCode(err error) string {
|
||
switch {
|
||
case errors.Is(err, ErrForbidden):
|
||
return "forbidden"
|
||
case errors.Is(err, ErrJobNotFound):
|
||
return "not_found"
|
||
case errors.Is(err, ErrJobNotCompleted):
|
||
return "job_not_completed"
|
||
case errors.Is(err, ErrActiveJobExists):
|
||
return "active_job_exists"
|
||
case errors.Is(err, ErrValidationFailed):
|
||
return "validation_failed"
|
||
case errors.Is(err, ErrPayloadTooLarge):
|
||
return "payload_too_large"
|
||
case errors.Is(err, ErrConverterUnavailable):
|
||
return "converter_unavailable"
|
||
case errors.Is(err, ErrFAAFileNotFound):
|
||
// 對外仍視為 faa_unavailable,避免揭露 object_key 不存在的內部細節。
|
||
// caller 想做精細處理用 errors.Is(err, ErrFAAFileNotFound) 直接判斷。
|
||
return "faa_unavailable"
|
||
case errors.Is(err, ErrFAAUnavailable):
|
||
return "faa_unavailable"
|
||
case errors.Is(err, ErrServiceBusy):
|
||
return "service_busy"
|
||
case errors.Is(err, ErrConverterAuthFailed):
|
||
// Phase 0.8b:對外刻意 mask 成 converter_unavailable(不揭露「API key 不對」內部狀態);
|
||
// caller 想做精細處理用 errors.Is(err, ErrConverterAuthFailed) 直接判斷(log / metric)。
|
||
return "converter_unavailable"
|
||
case errors.Is(err, ErrFAAAuthFailed):
|
||
// Phase 0.8b:對外刻意 mask 成 faa_unavailable,理由同上。
|
||
return "faa_unavailable"
|
||
case errors.Is(err, ErrStorageUnavailable):
|
||
return "storage_unavailable"
|
||
case errors.Is(err, ErrModelStoreUnavailable):
|
||
return "model_store_unavailable"
|
||
default:
|
||
return "internal_error"
|
||
}
|
||
}
|
||
|
||
// HTTPStatus 把 sentinel error 轉成對應的 HTTP status code。
|
||
//
|
||
// 未匹配的 error 回 500,handler 層應 log 後再 WriteError。
|
||
func HTTPStatus(err error) int {
|
||
switch {
|
||
case errors.Is(err, ErrForbidden):
|
||
return 403
|
||
case errors.Is(err, ErrJobNotFound):
|
||
return 404
|
||
case errors.Is(err, ErrJobNotCompleted), errors.Is(err, ErrActiveJobExists):
|
||
return 409
|
||
case errors.Is(err, ErrValidationFailed):
|
||
return 400
|
||
case errors.Is(err, ErrPayloadTooLarge):
|
||
return 413
|
||
case errors.Is(err, ErrConverterUnavailable),
|
||
errors.Is(err, ErrFAAUnavailable),
|
||
errors.Is(err, ErrFAAFileNotFound),
|
||
errors.Is(err, ErrConverterAuthFailed),
|
||
errors.Is(err, ErrFAAAuthFailed):
|
||
// Phase 0.8b:API key auth_failed 對外與「服務不可達」同層 502;
|
||
// 內部 log / metric 才區分(auth_failed = SRE alarm;其他 = 自然 retry)
|
||
return 502
|
||
case errors.Is(err, ErrStorageUnavailable), errors.Is(err, ErrModelStoreUnavailable):
|
||
// visionA 自身基礎設施問題 → 500(不是 502 gateway,因為非 upstream 失敗)
|
||
return 500
|
||
case errors.Is(err, ErrServiceBusy):
|
||
return 503
|
||
default:
|
||
return 500
|
||
}
|
||
}
|