feat(visionA-backend): Phase 0.8b v0.6 T3 — 砍 faa_client + ErrFAA* + s-3/s-4/s-5
對齊 ADR-016:visionA backend 不再直連 FAA、download 改走 converter GetResult。T3 砍除 v0.5 階段為 FAA delegated token 路線留的 faa_client.go 整檔 + 對應 sentinel + flow / e2e 殘留。 砍除: - internal/conversion/faa_client.go(整檔) - internal/conversion/faa_client_test.go(整檔) - errors.go: ErrFAAFileNotFound + ErrFAAAuthFailed 2 sentinel(+ ErrorCode/HTTPStatus mapping) - flow.go: faa FAAClient 欄位 + FlowOpts.FAA 必填 + a-h T3 預期清單 godoc - flow_test.go: flowStubFAA struct + newFlowStubFAA helper + fixture.faa - internal/api/conversion_test.go: TestConversion_Download_FAAAuthFailed - cmd/api-server/main.go: NewFAAClient wire + FAA: faaAPIClient field 保留: - ErrFAAUnavailable(converter promote 仍 PUT FAA、502 透傳路徑需要) - hashObjectKey helper 搬到 util.go(ownership 仍用) - e2e mockFAA 精簡為 regression-only(保留 negative assertion: FAA 0 命中)— reviewer 推薦雙層防護 新增(T3 必補,T1/T2 reviewer 累積): - s-3 TestDownloadStream_ConverterValidationFailed_Propagation(converter 4xx fallback → ErrValidationFailed 透傳) - s-4 TestPromoteToModels_StorageError_StreamClosed(instrumented stream wrapper 驗 fd leak 防護) - s-5 TestParseFilenameFromContentDisposition 9 個 sub-case(3 RFC 5987 + 5 hostile-input + 1 empty quoted) 發現:Go stdlib 自動 percent-decode RFC 5987 並寫入 params["filename"]、RFC 5987 優先於 ASCII filename T3 review M-1 修補(commit 內含): - internal/api/conversion.go:51,56 godoc + 501 user-facing message 從「FAA_BASE_URL」改為「VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY」 - 對齊 ADR-016 visionA 端不再有 FAA 直連設計 驗證: - B 層 verification 強制跑(reviewer 規定 T3 不接受暫緩): * 跨檔 grep: MC chain 0 / FAA functional refs 0 / TenantID 0 * API contract test: TestConversionE2E_DownloadStream 6 斷言含 FAA negative * 安全 manual review: path traversal / unbounded read / secret in log / error mask 4 項 - go build ./... exit 0 - go test -race -count=3 ./... 17 packages 全綠 - Reviewer 5 軸(v0.6-t3-review)⚠️→ ✅ 通過(M-1 已修) a-h 8 條清單 100% 達成(逐條 grep 驗收);mockFAA 選方案 1(保留 + negative assertion)— 雙層防護。 下一步: - T4 砍 ConversionConfig.FAAAPIKey/FAABaseURL + load.go env 讀取 + .env*.example + m-2 i18n dead case 一併 - T5 main.go startup log 整理 + e2e regression 防護 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ce6a657df4
commit
6024c294d3
@ -513,67 +513,60 @@ func converterJobToMap(j *conversion.ConverterJob) map[string]any {
|
|||||||
// 也只需 visionA 端有 HMAC_KEY;不需要 mock MC 端。
|
// 也只需 visionA 端有 HMAC_KEY;不需要 mock MC 端。
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// mockFAA — Phase 0.8b:模擬 FAA `GET /files/{key}` 回 NEF binary stream
|
// mockFAA — **Regression-only**(Phase 0.8b v0.6 T3 起)。
|
||||||
// (配合 download e2e 從 302 redirect 改 server-side stream proxy 模式)。
|
//
|
||||||
|
// 用途已演進:
|
||||||
|
// - Phase 0.8b v0.4 / v0.5:模擬 FAA `GET /files/{key}` 回 NEF binary stream,
|
||||||
|
// visionA backend 端真的會打它(API key 認證)
|
||||||
|
// - **Phase 0.8b v0.6 T3 起(本 commit)**:visionA backend 端不再直接打 FAA
|
||||||
|
// (ADR-016 撤回;FAAClient interface + faa_client.go 整檔刪除)。mockFAA server
|
||||||
|
// **保留**作為「e2e regression 防護」—— 若未來某 agent 不小心把 FAA 直連加回
|
||||||
|
// production code(例如「optimize: 直接打 FAA 跳一層」、或誤從 git history copy 舊
|
||||||
|
// 程式碼),`getCallCount.Load() == 0` 的 negative assertion 會立即 fail
|
||||||
|
// (TestConversionE2E_DownloadStream:1037-1048)。
|
||||||
|
//
|
||||||
|
// 為什麼**保留 mockFAA server**而不是純編譯期靜態斷言:
|
||||||
|
// - 純編譯期斷言(`var _ = (*conversion.FAAClient)(nil)`)已透過「FAAClient interface
|
||||||
|
// 不存在」自動成立 — production code 不可能再 import 不存在的 type
|
||||||
|
// - 但若有人**直接用 net/http 手寫**對 FAA 的 request(不透過 conversion package
|
||||||
|
// interface)、編譯期斷言抓不到。mockFAA server 在 e2e 層提供 wire-level 防護
|
||||||
|
// (只要 visionA 端對 mockFAA URL 發任何 GET request、getCallCount > 0 → test fail)
|
||||||
|
//
|
||||||
|
// 為什麼選方案 1(保留 mockFAA + negative assertion)而非方案 2(純結構性保證):
|
||||||
|
// - 方案 1 額外維護成本:50 行 mock + 1 行 assertion;功能性影響 0(mock 不會被 visionA
|
||||||
|
// 端打到)
|
||||||
|
// - 方案 2 強度只到「FAAClient interface 不存在」、抓不到 raw net/http 手寫的 regression
|
||||||
|
// - 採方案 1 給未來 ADR-016 設計約束多一層 wire-level 防護、cost-benefit 划算
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
type mockFAA struct {
|
type mockFAA struct {
|
||||||
srv *httptest.Server
|
srv *httptest.Server
|
||||||
|
|
||||||
// 收到的 Authorization header(測試驗 visionA 真有帶 API key)
|
// **regression-only**:v0.6 T3 後 visionA 端不應再對 mockFAA 發任何 request;
|
||||||
mu sync.Mutex
|
// 此 counter > 0 → 設計約束被破壞、立即 fail e2e
|
||||||
lastAuthHeader string
|
getCallCount atomic.Int32
|
||||||
getCallCount atomic.Int32
|
|
||||||
|
|
||||||
// nefPayload 是模擬的 NEF binary(由測試 setNEFPayload 設定);
|
|
||||||
// nil → 預設一個小 marker payload。
|
|
||||||
nefPayload []byte
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMockFAA(t *testing.T) *mockFAA {
|
func newMockFAA(t *testing.T) *mockFAA {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
m := &mockFAA{}
|
m := &mockFAA{}
|
||||||
m.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
m.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// 只處理 GET /files/...(對齊 FAA API spec)
|
// **v0.6 T3 起**:任何 request 進來都應視為設計違規;用 counter 記下,讓 e2e 斷言抓到
|
||||||
|
m.getCallCount.Add(1)
|
||||||
|
// 仍照舊處理 GET /files/... 以避免 visionA 端因連線失敗發生 panic(讓 e2e 拿到具體
|
||||||
|
// 「不該有的呼叫」而非「連線錯誤」)—— 但 production code 不應走到這條 path
|
||||||
if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/files/") {
|
if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/files/") {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.getCallCount.Add(1)
|
|
||||||
m.mu.Lock()
|
|
||||||
m.lastAuthHeader = r.Header.Get("Authorization")
|
|
||||||
payload := m.nefPayload
|
|
||||||
m.mu.Unlock()
|
|
||||||
if payload == nil {
|
|
||||||
payload = []byte("mock-nef-default-payload")
|
|
||||||
}
|
|
||||||
// 模擬 FAA 回 NEF binary stream
|
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
|
w.Header().Set("Content-Length", "0")
|
||||||
w.Header().Set("ETag", "etag-mock-faa")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write(payload)
|
|
||||||
}))
|
}))
|
||||||
t.Cleanup(m.srv.Close)
|
t.Cleanup(m.srv.Close)
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// setNEFPayload 設定下一個(與後續所有)GET /files 回的 binary 內容;
|
|
||||||
// 測試端以此控制「user 下載到的 bytes」。
|
|
||||||
func (m *mockFAA) setNEFPayload(payload []byte) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
m.nefPayload = payload
|
|
||||||
}
|
|
||||||
|
|
||||||
// getLastAuthHeader 取最後一次 GET /files 收到的 Authorization header
|
|
||||||
// (測試驗 visionA 帶上正確 Bearer <FAA_API_KEY>)。
|
|
||||||
func (m *mockFAA) getLastAuthHeader() string {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
return m.lastAuthHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// conversionFixture — 把所有 server 拼起來並提供 OIDC 登入 helper
|
// conversionFixture — 把所有 server 拼起來並提供 OIDC 登入 helper
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@ -599,14 +592,19 @@ func (f *conversionFixture) Close() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// setupConversionFixture 建立完整的 e2e 環境:
|
// setupConversionFixture 建立完整的 e2e 環境:
|
||||||
// - mock converter / FAA(Phase 0.8b 後不再 wire mock MC — API key 模式)
|
// - mock converter(API key 模式)
|
||||||
|
// - mock FAA(**Phase 0.8b v0.6 T3 起為 regression-only**;visionA 端不應再對它發 request;
|
||||||
|
// 若 getCallCount > 0 表示設計約束被破壞,由 e2e negative assertion 抓出)
|
||||||
// - fake OIDC(給 user 走 cookie session 登入)
|
// - fake OIDC(給 user 走 cookie session 登入)
|
||||||
// - visionA-backend router(含 conversion service wired,仿 main.go wire 邏輯)
|
// - visionA-backend router(含 conversion service wired,仿 main.go wire 邏輯)
|
||||||
//
|
//
|
||||||
// **不影響 T1-T7 既有 code**:本 fixture 完全獨立,不重用 setupFixture(後者沒 wire conversion)。
|
// **不影響 T1-T7 既有 code**:本 fixture 完全獨立,不重用 setupFixture(後者沒 wire conversion)。
|
||||||
//
|
//
|
||||||
// Phase 0.8b T5:服務間認證改 pre-shared API key(ADR-015);mock MC / mcTokenClient
|
// Phase 0.8b T5:服務間認證改 pre-shared API key(ADR-015);mock MC / mcTokenClient
|
||||||
// 已從 fixture 移除;download 走 server-side stream proxy,mockFAA 直接回 NEF binary。
|
// 已從 fixture 移除。
|
||||||
|
// Phase 0.8b v0.6 T3:FAA wire 從 conversion.FlowOpts 移除(FAAClient interface 已砍);
|
||||||
|
// mockFAA server 保留作為「visionA 端不再直接打 FAA」的 regression 防護
|
||||||
|
// (TestConversionE2E_DownloadStream:1037-1048 的 negative assertion 仍生效)。
|
||||||
func setupConversionFixture(t *testing.T) *conversionFixture {
|
func setupConversionFixture(t *testing.T) *conversionFixture {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@ -655,14 +653,17 @@ func setupConversionFixture(t *testing.T) *conversionFixture {
|
|||||||
//
|
//
|
||||||
// Phase 0.8b T5:服務間認證改 pre-shared API key(ADR-015);
|
// Phase 0.8b T5:服務間認證改 pre-shared API key(ADR-015);
|
||||||
// - 不再 wire MCTokenClient / Tokens 欄位
|
// - 不再 wire MCTokenClient / Tokens 欄位
|
||||||
// - converter / FAA client 各自帶 fixture 用的 API key
|
// - converter client 帶 fixture 用的 API key
|
||||||
// - mock converter / FAA 端不驗 key(測試重點是 visionA 端的 wire 行為與 stream proxy)
|
// - mock converter 端不驗 key(測試重點是 visionA 端的 wire 行為與 stream proxy)
|
||||||
//
|
//
|
||||||
// 注意:converter_client / faa_client 都用 5s timeout HTTPClient 避免測試卡死;
|
// Phase 0.8b v0.6 T3:visionA 端不再 wire FAA client(FAAClient interface 已砍);
|
||||||
|
// download / promote 都走 converter.GetResult。mockFAA server 仍 spin up 作為
|
||||||
|
// regression-only 防護(見 newMockFAA godoc)。
|
||||||
|
//
|
||||||
|
// 注意:converter_client 用 5s timeout HTTPClient 避免測試卡死;
|
||||||
// 對 mock servers 來說連線秒回,timeout 不會觸發。
|
// 對 mock servers 來說連線秒回,timeout 不會觸發。
|
||||||
fastHTTP := &http.Client{Timeout: 5 * time.Second}
|
fastHTTP := &http.Client{Timeout: 5 * time.Second}
|
||||||
const fixtureConverterAPIKey = "fixture-converter-api-key-do-not-use-in-prod-aaaaaaaaaaaaaaaaaa"
|
const fixtureConverterAPIKey = "fixture-converter-api-key-do-not-use-in-prod-aaaaaaaaaaaaaaaaaa"
|
||||||
const fixtureFAAAPIKey = "fixture-faa-api-key-do-not-use-in-prod-bbbbbbbbbbbbbbbbbbbbbbbbbb"
|
|
||||||
converterAPIClient := conversion.NewConverterClient(conversion.ConverterClientOpts{
|
converterAPIClient := conversion.NewConverterClient(conversion.ConverterClientOpts{
|
||||||
BaseURL: conv.srv.URL,
|
BaseURL: conv.srv.URL,
|
||||||
APIKey: fixtureConverterAPIKey,
|
APIKey: fixtureConverterAPIKey,
|
||||||
@ -670,12 +671,6 @@ func setupConversionFixture(t *testing.T) *conversionFixture {
|
|||||||
InitHTTPClient: &http.Client{Timeout: 60 * time.Second}, // 場景 #1 大 body 給寬一點
|
InitHTTPClient: &http.Client{Timeout: 60 * time.Second}, // 場景 #1 大 body 給寬一點
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
})
|
})
|
||||||
faaAPIClient := conversion.NewFAAClient(conversion.FAAClientOpts{
|
|
||||||
BaseURL: faa.srv.URL,
|
|
||||||
APIKey: fixtureFAAAPIKey,
|
|
||||||
HTTPClient: fastHTTP,
|
|
||||||
Logger: logger,
|
|
||||||
})
|
|
||||||
ownership := conversion.NewOwnership(converterAPIClient, logger)
|
ownership := conversion.NewOwnership(converterAPIClient, logger)
|
||||||
|
|
||||||
modelRepo := model.NewInMemoryRepository()
|
modelRepo := model.NewInMemoryRepository()
|
||||||
@ -684,7 +679,6 @@ func setupConversionFixture(t *testing.T) *conversionFixture {
|
|||||||
|
|
||||||
conversionService, err := conversion.NewService(conversion.FlowOpts{
|
conversionService, err := conversion.NewService(conversion.FlowOpts{
|
||||||
Converter: converterAPIClient,
|
Converter: converterAPIClient,
|
||||||
FAA: faaAPIClient,
|
|
||||||
Ownership: ownership,
|
Ownership: ownership,
|
||||||
ModelStore: modelStoreAdapter,
|
ModelStore: modelStoreAdapter,
|
||||||
Storage: storageAdapter,
|
Storage: storageAdapter,
|
||||||
@ -1034,18 +1028,23 @@ func TestConversionE2E_DownloadStream(t *testing.T) {
|
|||||||
assert.Empty(t, resp.Header.Get("Location"),
|
assert.Empty(t, resp.Header.Get("Location"),
|
||||||
"Phase 0.8b 不應有 Location header(無 redirect 流程)")
|
"Phase 0.8b 不應有 Location header(無 redirect 流程)")
|
||||||
|
|
||||||
// === 斷言 6(v0.6 新增):visionA 端不再直接打 FAA ===
|
// === 斷言 6:visionA 端不再直接打 FAA(v0.6 設計約束 + T3 雙層防護)===
|
||||||
//
|
//
|
||||||
// 這條斷言是 **ADR-016 §1 設計約束的 regression 防護**:visionA 端不再直接呼叫 FAA,
|
// 這條斷言是 **ADR-016 §1 設計約束的 regression 防護**:visionA 端不再直接呼叫 FAA,
|
||||||
// 整條 download path 改走 converter MinIO。萬一未來某 agent 不小心把 `f.faa.GetFile`
|
// 整條 download path 改走 converter MinIO。萬一未來某 agent 不小心加回 FAA 直連
|
||||||
// 加回 production code(例如「optimize: 直接打 FAA 跳一層」),此 e2e 立即 fail。
|
// (例如「optimize: 直接打 FAA 跳一層」、或從 git history 誤 copy 舊程式碼),此 e2e
|
||||||
// 比依賴 reviewer 抓更可靠。
|
// 立即 fail。
|
||||||
//
|
//
|
||||||
// **T3 計畫**:T3 砍 faa_client.go 整檔後,mock FAA + `f.faa.getCallCount` 計數器都會
|
// **T3 後的雙層防護**(reviewer 推薦方案 1:保留 mockFAA + negative assertion):
|
||||||
// 一起砍;此 assertion 應改為「wire 點不存在 FAA dependency」的編譯期靜態斷言
|
// 1. **編譯期保證**(v0.6 T3 新加):`FAAClient` interface + `faa_client.go` 整檔已砍除;
|
||||||
// (e.g. `var _ = (*conversion.FAAClient)(nil)` 不應 compile),維持同等強度的 regression 防護。
|
// production code 不可能再 import `conversion.FAAClient` 或 `conversion.NewFAAClient`
|
||||||
|
// (type 不存在 → 編譯 fail)
|
||||||
|
// 2. **執行期保證**(本斷言):mockFAA server 仍 spin up;若有人直接用 net/http 手寫
|
||||||
|
// 對 FAA URL 的 request(繞過 conversion package interface),getCallCount > 0 →
|
||||||
|
// e2e fail。涵蓋編譯期斷言無法抓的「raw HTTP 直連」regression
|
||||||
assert.Equal(t, int32(0), f.faa.getCallCount.Load(),
|
assert.Equal(t, int32(0), f.faa.getCallCount.Load(),
|
||||||
"v0.6:visionA 端不應再直接打 FAA(download path 改走 converter `/result`)")
|
"v0.6 T3:visionA 端不應再直接打 FAA(download path 改走 converter `/result`;"+
|
||||||
|
"FAAClient interface 已整檔砍除作為第一層編譯期防護,此斷言為第二層執行期防護)")
|
||||||
// 驗 mock converter `/result` 真的被打到(防 wire 路徑錯)
|
// 驗 mock converter `/result` 真的被打到(防 wire 路徑錯)
|
||||||
assert.GreaterOrEqual(t, int(f.conv.getResultCallCount.Load()), 1,
|
assert.GreaterOrEqual(t, int(f.conv.getResultCallCount.Load()), 1,
|
||||||
"mock converter GET /api/v1/jobs/{id}/result 應至少被打一次")
|
"mock converter GET /api/v1/jobs/{id}/result 應至少被打一次")
|
||||||
|
|||||||
@ -138,15 +138,19 @@ func main() {
|
|||||||
converterClient := converter.NewStubClient()
|
converterClient := converter.NewStubClient()
|
||||||
|
|
||||||
// ===== Phase 0.8 / 0.8b Conversion(轉檔功能整合) =====
|
// ===== Phase 0.8 / 0.8b Conversion(轉檔功能整合) =====
|
||||||
// 對齊 .autoflow/04-architecture/conversion.md、ADR-015。
|
// 對齊 docs/autoflow/04-architecture/conversion.md、ADR-015、ADR-016。
|
||||||
//
|
//
|
||||||
// 啟用條件:cfg.Conversion.Enabled() —
|
// 啟用條件:cfg.Conversion.Enabled() —
|
||||||
// ConverterBaseURL + FAABaseURL + ConverterAPIKey + FAAAPIKey 全部非空。
|
// 由 ConverterBaseURL + ConverterAPIKey 決定(FAABaseURL / FAAAPIKey 由 T4 砍除 env 校驗)。
|
||||||
// 不啟用時 deps.Conversion 為 nil,5 個 endpoint 自動回 501(registerConversionRoutes 處理)。
|
// 不啟用時 deps.Conversion 為 nil,5 個 endpoint 自動回 501(registerConversionRoutes 處理)。
|
||||||
//
|
//
|
||||||
// **Phase 0.8b T5**:完全切換至 pre-shared API key 認證 — 不再 wire MCTokenClient、
|
// **Phase 0.8b T5**:完全切換至 pre-shared API key 認證 — 不再 wire MCTokenClient、
|
||||||
// 不再讀 OIDCConfig.ServiceClientID/Secret、不再有 tenant_id / delegated_ttl_sec
|
// 不再讀 OIDCConfig.ServiceClientID/Secret、不再有 tenant_id / delegated_ttl_sec
|
||||||
// 概念。參見 ADR-015 §6 變更影響清單。
|
// 概念。參見 ADR-015 §6 變更影響清單。
|
||||||
|
//
|
||||||
|
// **Phase 0.8b v0.6 T3**:撤回 visionA → FAA 直接呼叫(ADR-016 撤回 v0.5 設計缺口)。
|
||||||
|
// faa_client.go / FAAClient interface / FlowOpts.FAA 全部砍除;download / promote 流程
|
||||||
|
// 改走 converter.GetResult。FAABaseURL / FAAAPIKey env 仍保留在 config 直到 T4 一併砍。
|
||||||
var conversionService conversion.Service
|
var conversionService conversion.Service
|
||||||
if cfg.Conversion.Enabled() {
|
if cfg.Conversion.Enabled() {
|
||||||
// 不再檢查 ServiceClientID/Secret —— Phase 0.8b 起 conversion 不依賴 OIDC service client。
|
// 不再檢查 ServiceClientID/Secret —— Phase 0.8b 起 conversion 不依賴 OIDC service client。
|
||||||
@ -157,11 +161,6 @@ func main() {
|
|||||||
APIKey: cfg.Conversion.ConverterAPIKey,
|
APIKey: cfg.Conversion.ConverterAPIKey,
|
||||||
Logger: log,
|
Logger: log,
|
||||||
})
|
})
|
||||||
faaAPIClient := conversion.NewFAAClient(conversion.FAAClientOpts{
|
|
||||||
BaseURL: cfg.Conversion.FAABaseURL,
|
|
||||||
APIKey: cfg.Conversion.FAAAPIKey,
|
|
||||||
Logger: log,
|
|
||||||
})
|
|
||||||
ownership := conversion.NewOwnership(converterAPIClient, log)
|
ownership := conversion.NewOwnership(converterAPIClient, log)
|
||||||
|
|
||||||
// narrow adapter(避免 conversion 直接 import internal/model / internal/storage)
|
// narrow adapter(避免 conversion 直接 import internal/model / internal/storage)
|
||||||
@ -171,7 +170,6 @@ func main() {
|
|||||||
var convErr error
|
var convErr error
|
||||||
conversionService, convErr = conversion.NewService(conversion.FlowOpts{
|
conversionService, convErr = conversion.NewService(conversion.FlowOpts{
|
||||||
Converter: converterAPIClient,
|
Converter: converterAPIClient,
|
||||||
FAA: faaAPIClient,
|
|
||||||
Ownership: ownership,
|
Ownership: ownership,
|
||||||
ModelStore: modelStoreAdapter,
|
ModelStore: modelStoreAdapter,
|
||||||
Storage: storageAdapter,
|
Storage: storageAdapter,
|
||||||
@ -183,12 +181,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Info("conversion service initialized",
|
log.Info("conversion service initialized",
|
||||||
"converter_base_url", cfg.Conversion.ConverterBaseURL,
|
"converter_base_url", cfg.Conversion.ConverterBaseURL,
|
||||||
"faa_base_url", cfg.Conversion.FAABaseURL,
|
|
||||||
// 安全:絕不印 key 全文 — 對齊 ADR-015 §3.5.3 部署檢查清單 #4
|
// 安全:絕不印 key 全文 — 對齊 ADR-015 §3.5.3 部署檢查清單 #4
|
||||||
"converter_api_key_set", cfg.Conversion.ConverterAPIKey != "",
|
"converter_api_key_set", cfg.Conversion.ConverterAPIKey != "")
|
||||||
"faa_api_key_set", cfg.Conversion.FAAAPIKey != "")
|
|
||||||
} else {
|
} else {
|
||||||
log.Info("conversion service disabled (set VISIONA_CONVERTER_BASE_URL + VISIONA_FAA_BASE_URL + VISIONA_CONVERTER_API_KEY + VISIONA_FAA_API_KEY to enable)")
|
log.Info("conversion service disabled (set VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY to enable)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Seed demo data(可選) =====
|
// ===== Seed demo data(可選) =====
|
||||||
|
|||||||
@ -48,12 +48,14 @@ import (
|
|||||||
//
|
//
|
||||||
// 由 NewRouter 在 apiGroup(OIDC AuthMiddleware 已套)下呼叫;
|
// 由 NewRouter 在 apiGroup(OIDC AuthMiddleware 已套)下呼叫;
|
||||||
// 若 deps.Conversion 為 nil(Phase 0.8 conversion 未啟用,例如 dev 環境沒設
|
// 若 deps.Conversion 為 nil(Phase 0.8 conversion 未啟用,例如 dev 環境沒設
|
||||||
// CONVERTER_BASE_URL / FAA_BASE_URL)→ 5 個 endpoint 一律回 501。
|
// VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY)→ 5 個 endpoint 一律回 501。
|
||||||
|
// Phase 0.8b v0.6(ADR-016):visionA 端不再直連 FAA、download 改走 converter
|
||||||
|
// GET /api/v1/jobs/{id}/result,因此不再需要 FAA env。
|
||||||
func registerConversionRoutes(g *gin.RouterGroup, deps Deps) {
|
func registerConversionRoutes(g *gin.RouterGroup, deps Deps) {
|
||||||
if deps.Conversion == nil {
|
if deps.Conversion == nil {
|
||||||
// 未啟用 — 註冊 501 stub,避免 404(讓 frontend 拿到明確 NOT_IMPLEMENTED)
|
// 未啟用 — 註冊 501 stub,避免 404(讓 frontend 拿到明確 NOT_IMPLEMENTED)
|
||||||
notImpl := func(c *gin.Context) {
|
notImpl := func(c *gin.Context) {
|
||||||
WriteNotImplemented(c, "conversion service is not configured (set VISIONA_CONVERTER_BASE_URL + VISIONA_FAA_BASE_URL)")
|
WriteNotImplemented(c, "conversion service is not configured (set VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY)")
|
||||||
}
|
}
|
||||||
conv := g.Group("/conversion")
|
conv := g.Group("/conversion")
|
||||||
conv.POST("/init", notImpl)
|
conv.POST("/init", notImpl)
|
||||||
@ -270,15 +272,17 @@ func conversionPromoteHandler(deps Deps) gin.HandlerFunc {
|
|||||||
// - 失敗:不寫 200,依 sentinel 走 handleConversionError 回 JSON
|
// - 失敗:不寫 200,依 sentinel 走 handleConversionError 回 JSON
|
||||||
// - Cache-Control: no-store — 避免 browser 對私有檔案 cache
|
// - Cache-Control: no-store — 避免 browser 對私有檔案 cache
|
||||||
//
|
//
|
||||||
// Phase 0.8 → 0.8b 差異:
|
// Phase 0.8 → 0.8b → v0.6 演進:
|
||||||
// - Phase 0.8:visionA → MC 換 delegated token → c.Redirect(302, FAA_URL_with_token)
|
// - Phase 0.8:visionA → MC 換 delegated token → c.Redirect(302, FAA_URL_with_token)
|
||||||
// - Phase 0.8b:visionA backend 用 API key 直接拉 FAA → io.Copy(c.Writer, stream)
|
// - Phase 0.8b v0.4/v0.5:visionA backend 用 API key 直接拉 FAA → io.Copy(c.Writer, stream)
|
||||||
// 沒有 token 結構性流經 frontend;不需 FAA CORS(server-side outbound HTTP)
|
// - **Phase 0.8b v0.6**(ADR-016):visionA backend 改走 `converter.GetResult` 從 converter
|
||||||
|
// MinIO 拉 NEF stream(visionA 端不再直接打 FAA、撤回 v0.5 設計缺口);handler 端
|
||||||
|
// io.Copy(c.Writer, stream) 路徑不變、只是 stream 來源換成 converter
|
||||||
//
|
//
|
||||||
// 中途錯誤處理(已 200 / 已寫 part of body 後失敗):
|
// 中途錯誤處理(已 200 / 已寫 part of body 後失敗):
|
||||||
// - 一旦 status 200 已寫,無法再改 status 給 client(HTTP 規範)
|
// - 一旦 status 200 已寫,無法再改 status 給 client(HTTP 規範)
|
||||||
// - io.Copy 中斷只能 log 錯誤;client 端 browser 會看到截斷檔
|
// - io.Copy 中斷只能 log 錯誤;client 端 browser 會看到截斷檔
|
||||||
// - ctx cancel(client 斷線)由 FAAClient 內部 ctx-aware 透傳,goroutine 自動結束
|
// - ctx cancel(client 斷線)由 ConverterClient 內部 ctx-aware 透傳,goroutine 自動結束
|
||||||
func conversionDownloadHandler(deps Deps) gin.HandlerFunc {
|
func conversionDownloadHandler(deps Deps) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uc, ok := UserContextFrom(c)
|
uc, ok := UserContextFrom(c)
|
||||||
|
|||||||
@ -657,30 +657,10 @@ func TestConversion_Download_FAAUnavailable(t *testing.T) {
|
|||||||
assert.Contains(t, w.Body.String(), "faa_unavailable")
|
assert.Contains(t, w.Body.String(), "faa_unavailable")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConversion_Download_FAAAuthFailed:API key 不對齊(運維事件)
|
// Phase 0.8b v0.6 T3:TestConversion_Download_FAAAuthFailed 已整段移除
|
||||||
// → handler 回 502,對外 mask 成 faa_unavailable(不洩漏「API key 不對」)。
|
// (visionA 端不再直接打 FAA、ErrFAAAuthFailed sentinel 已砍除;ADR-016)。
|
||||||
//
|
// 對應的 converter API key 對外 mask 行為由 TestConversion_Download_ConverterAuthFailed 涵蓋
|
||||||
// 對齊 ADR-015 §3.5.3 #3「對外只回 unauthorized」原則 + conversion.md §6 mask 行為:
|
// (v0.6 後 download path 改走 converter,auth 失敗統一收斂到 ErrConverterAuthFailed)。
|
||||||
// SRE 從 server log 的 ErrFAAAuthFailed sentinel 排查 env,但對 frontend 文字一致。
|
|
||||||
func TestConversion_Download_FAAAuthFailed(t *testing.T) {
|
|
||||||
svc := &stubConversionService{
|
|
||||||
DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) {
|
|
||||||
return nil, nil, conversion.ErrFAAAuthFailed
|
|
||||||
},
|
|
||||||
}
|
|
||||||
r := newConversionFixture(t, svc)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/conversion/job/download", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// 對外 502 + faa_unavailable(mask)— 不要洩漏 auth_failed 這個內部運維狀態
|
|
||||||
assert.Equal(t, http.StatusBadGateway, w.Code)
|
|
||||||
assert.Contains(t, w.Body.String(), "faa_unavailable",
|
|
||||||
"ErrFAAAuthFailed 對外應 mask 成 faa_unavailable,不洩漏 API key 不對齊細節")
|
|
||||||
assert.NotContains(t, w.Body.String(), "auth_failed",
|
|
||||||
"對 frontend 不應暴露 auth_failed 這個內部 SRE 訊號")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConversion_Download_ConverterAuthFailed:對稱測試 converter API key 不對齊。
|
// TestConversion_Download_ConverterAuthFailed:對稱測試 converter API key 不對齊。
|
||||||
// 對外 mask 成 converter_unavailable。
|
// 對外 mask 成 converter_unavailable。
|
||||||
|
|||||||
@ -1272,6 +1272,23 @@ func TestGetResult_EmptyJobID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestParseFilenameFromContentDisposition:cover parser 的 happy / empty / malformed case。
|
// TestParseFilenameFromContentDisposition:cover parser 的 happy / empty / malformed case。
|
||||||
|
//
|
||||||
|
// v0.6 T3 s-5 補強(reviewer T1 提的 RFC 5987 encoded form + hostile-input sub-case):
|
||||||
|
// - RFC 5987 encoded form(`filename*=UTF-8''...`)— 驗 Go stdlib `mime.ParseMediaType`
|
||||||
|
// 對 charset-encoded `filename*` 參數的 transparent 解碼行為
|
||||||
|
// - Hostile-input:CRLF injection / path traversal / null byte / extreme length
|
||||||
|
//
|
||||||
|
// **重要發現**:Go stdlib `mime.ParseMediaType` 對 `filename*=UTF-8''...` 形式
|
||||||
|
// **自動 percent-decode** 並寫入 `params["filename"]`(不需 caller 端額外讀 `filename*`)。
|
||||||
|
// 即 parser 取回的字串會是 UTF-8 解碼後的值(如 `foo_✓.nef`),而非 raw URL-encoded
|
||||||
|
// (`foo_%E2%9C%93.nef`)。**且 RFC 5987 form 優先於 ASCII filename**(當兩者並存時)。
|
||||||
|
// 此行為對 visionA 無影響:
|
||||||
|
// - flow.DownloadStream 對 filename 不依賴此 parser;用 defaultDownloadFilename(cj) 覆寫
|
||||||
|
// - converter 端 ADR-016 §1.2 給的 filename 是 ASCII(`<stem>_<chip>.nef`)、不會走到 RFC 5987 path
|
||||||
|
//
|
||||||
|
// Hostile-input 防護:mime.ParseMediaType 對 malformed input(CRLF)回 error → parser 回
|
||||||
|
// 空字串、不 panic、不洩漏 raw input;visionA-backend handler (api/conversion.go) 對
|
||||||
|
// filename 另有 sanitize 層(CRLF / quote 移除),不依賴此 parser 做 sanitize。
|
||||||
func TestParseFilenameFromContentDisposition(t *testing.T) {
|
func TestParseFilenameFromContentDisposition(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@ -1286,6 +1303,31 @@ func TestParseFilenameFromContentDisposition(t *testing.T) {
|
|||||||
{"malformed_no_attachment", `;;;`, ""},
|
{"malformed_no_attachment", `;;;`, ""},
|
||||||
{"missing_filename_param", `attachment`, ""},
|
{"missing_filename_param", `attachment`, ""},
|
||||||
{"inline_disposition", `inline; filename="foo.bin"`, "foo.bin"},
|
{"inline_disposition", `inline; filename="foo.bin"`, "foo.bin"},
|
||||||
|
// === v0.6 T3 s-5 補強 ===
|
||||||
|
// RFC 5987 encoded form — Go stdlib mime.ParseMediaType 自動 percent-decode 為 UTF-8
|
||||||
|
// 並寫進 params["filename"](透明行為);對 ASCII-only 值結果就是原字串
|
||||||
|
{"rfc5987_utf8_ascii_only", `attachment; filename*=UTF-8''yolov5s_kl720.nef`, "yolov5s_kl720.nef"},
|
||||||
|
// RFC 5987 含 lang tag(`UTF-8'en'foo.nef`)— 同上、結果為解碼後 ASCII
|
||||||
|
{"rfc5987_with_lang", `attachment; filename*=UTF-8'en'foo.nef`, "foo.nef"},
|
||||||
|
// RFC 5987 含 percent-encoded UTF-8(✓ 字元)— stdlib 解碼後回 UTF-8 字串
|
||||||
|
// 且 RFC 5987 form **優先於** ASCII fallback(當兩者並存時,stdlib 取 `filename*` 值)
|
||||||
|
{"rfc5987_utf8_with_unicode", `attachment; filename="foo.nef"; filename*=UTF-8''foo_%E2%9C%93.nef`, "foo_✓.nef"},
|
||||||
|
// Hostile-input 1:CRLF injection(HTTP response splitting 攻擊向量)
|
||||||
|
// `mime.ParseMediaType` 對含 CR/LF 的 header 回 error → parser 回空字串
|
||||||
|
{"hostile_crlf_injection", "attachment; filename=\"foo.nef\r\nSet-Cookie: evil=1\"", ""},
|
||||||
|
// Hostile-input 2:path traversal — parser 端不做 sanitize(responsibility 在 handler);
|
||||||
|
// 此處只驗 parser 不 panic、不丟錯,把 raw `../../etc/passwd` 字串原樣傳出(caller 端
|
||||||
|
// 後續的 defaultDownloadFilename / handler sanitize 會處理)
|
||||||
|
{"hostile_path_traversal", `attachment; filename="../../etc/passwd"`, "../../etc/passwd"},
|
||||||
|
// Hostile-input 3:null byte injection — `mime.ParseMediaType` 容忍 null byte
|
||||||
|
// (規範未明禁),parser 把 raw 字串傳出(同上、由後續 layer sanitize)
|
||||||
|
{"hostile_null_byte", "attachment; filename=\"foo\x00.nef\"", "foo\x00.nef"},
|
||||||
|
// Hostile-input 4:extreme length(4KB filename)— parser 不限制長度、回 raw 字串
|
||||||
|
// 對 visionA 影響:0(response header 4KB 在 Content-Disposition 內早就被 net/http
|
||||||
|
// 拒絕;此 case 純驗 parser 不 OOM / 不 panic)
|
||||||
|
{"hostile_extreme_length", `attachment; filename="` + strings.Repeat("A", 4096) + `"`, strings.Repeat("A", 4096)},
|
||||||
|
// Hostile-input 5:empty quoted string — 合法格式、回空字串
|
||||||
|
{"empty_quoted_filename", `attachment; filename=""`, ""},
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
tc := tc
|
tc := tc
|
||||||
|
|||||||
@ -43,18 +43,26 @@ var (
|
|||||||
// 對應 HTTP 502 / code "converter_unavailable"。
|
// 對應 HTTP 502 / code "converter_unavailable"。
|
||||||
ErrConverterUnavailable = errors.New("conversion: converter unavailable")
|
ErrConverterUnavailable = errors.New("conversion: converter unavailable")
|
||||||
|
|
||||||
// ErrFAAUnavailable — FAA 5xx / network 持續失敗。
|
// ErrFAAUnavailable — converter 端對 FAA 推送 NEF 失敗(converter 回 502 file_gateway_unavailable)。
|
||||||
|
//
|
||||||
|
// Phase 0.8b v0.6(T3)後語意調整:visionA 端不再直接呼叫 FAA(ADR-016 撤回),
|
||||||
|
// 此 sentinel 改由 `converter_client.go` 的 promote response mapping 使用 ——
|
||||||
|
// 當 converter promote 內部 PUT FAA 失敗時,converter 回 502 `file_gateway_unavailable`,
|
||||||
|
// visionA-backend 透傳成 `ErrFAAUnavailable` 給 handler 對外 502 + `faa_unavailable`。
|
||||||
|
//
|
||||||
|
// 與 ErrConverterUnavailable 區分:
|
||||||
|
// - ErrConverterUnavailable:converter scheduler 本身不可達 / 5xx(visionA → converter 失敗)
|
||||||
|
// - ErrFAAUnavailable:converter 可達、但 converter → FAA push 失敗(運維告警打 FAA team)
|
||||||
|
//
|
||||||
// 對應 HTTP 502 / code "faa_unavailable"。
|
// 對應 HTTP 502 / code "faa_unavailable"。
|
||||||
ErrFAAUnavailable = errors.New("conversion: faa unavailable")
|
ErrFAAUnavailable = errors.New("conversion: faa unavailable")
|
||||||
|
|
||||||
// ErrFAAFileNotFound — FAA 回 404(指定 object_key 不存在)。
|
// Phase 0.8b v0.6 T3 移除(visionA 端不再直接打 FAA、相關 sentinel 不再被觸發):
|
||||||
// 觸發情境:promote-to-models 流程 promoted 後 FAA pull 卻找不到檔(罕見:
|
// - ErrFAAFileNotFound — FAA `GET /files/{key}` 404(visionA 端已無此 call path)
|
||||||
// converter promote 才剛寫 FAA、應立即可見)— 可能 FAA 端 GC、或 object_key 命名邏輯有 bug。
|
// - ErrFAAAuthFailed — visionA → FAA API key 401/403(visionA 端已無此 call path)
|
||||||
// 對應 HTTP 502 / code "faa_unavailable"(對外仍視為 FAA 不可用,避免揭露內部 object key 細節)。
|
// 取代:FAA 相關失敗模式收斂到 converter 端透傳(converter promote 失敗 → ErrFAAUnavailable)。
|
||||||
// caller(flow.go)可用 errors.Is(err, ErrFAAFileNotFound) 做精細處理(log / metric)。
|
// **不重用舊 sentinel name**(同 T3 ErrIDPUnavailable 規範;Phase 1+ 若未來再加 FAA 直連
|
||||||
//
|
// 路徑,採新 sentinel name 避免閱讀 git log 時混淆語意)。
|
||||||
// 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。
|
// ErrServiceBusy — converter 端回 503 service_busy。
|
||||||
// 對應 HTTP 503 / code "service_busy"。
|
// 對應 HTTP 503 / code "service_busy"。
|
||||||
@ -84,8 +92,9 @@ var (
|
|||||||
// - ErrIDPMisconfigured — MC token endpoint 4xx(client_credentials grant 設定錯誤)
|
// - ErrIDPMisconfigured — MC token endpoint 4xx(client_credentials grant 設定錯誤)
|
||||||
// - ErrIDPUnavailable — MC oauth/token 5xx / network 持續失敗
|
// - ErrIDPUnavailable — MC oauth/token 5xx / network 持續失敗
|
||||||
// - ErrServiceClientUnauthorized — visionA → MC 認證失敗(401/403)
|
// - ErrServiceClientUnauthorized — visionA → MC 認證失敗(401/403)
|
||||||
// 取代:401/403 改 mapping 到 ErrConverterAuthFailed / ErrFAAAuthFailed(下方);
|
// 取代:401/403 改 mapping 到 ErrConverterAuthFailed(下方);
|
||||||
// converter 端 503 改 mapping 到 ErrConverterUnavailable(converter_client.go mapPromoteError)。
|
// converter 端 503 改 mapping 到 ErrConverterUnavailable(converter_client.go mapPromoteError)。
|
||||||
|
// (Phase 0.8b v0.6 T3 加註:原 v0.4/v0.5 引入的 ErrFAAAuthFailed 也已砍除,見上方說明。)
|
||||||
//
|
//
|
||||||
// **不重用舊 sentinel name**(Phase 1+ 注意):上述 5 個 sentinel name 已從 git history
|
// **不重用舊 sentinel name**(Phase 1+ 注意):上述 5 個 sentinel name 已從 git history
|
||||||
// 中砍除,未來若需要新增 sentinel 不應重用同名(如 `ErrIDPUnavailable`),以免閱讀
|
// 中砍除,未來若需要新增 sentinel 不應重用同名(如 `ErrIDPUnavailable`),以免閱讀
|
||||||
@ -110,17 +119,7 @@ var (
|
|||||||
// Phase 0.8b conversion (見 ADR-015 §6 / conversion.md §6 / api-conversion.md)
|
// Phase 0.8b conversion (見 ADR-015 §6 / conversion.md §6 / api-conversion.md)
|
||||||
ErrConverterAuthFailed = errors.New("conversion: converter api key auth failed")
|
ErrConverterAuthFailed = errors.New("conversion: converter api key auth failed")
|
||||||
|
|
||||||
// ErrFAAAuthFailed — visionA-backend → FAA 帶的 API key 不對齊(FAA middleware 401 / 403)。
|
// Phase 0.8b v0.6 T3 移除:ErrFAAAuthFailed(同上方說明)。
|
||||||
//
|
|
||||||
// 觸發情境(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)寫入或讀取失敗。
|
// ErrStorageUnavailable — visionA 自家 storage(local FS / S3)寫入或讀取失敗。
|
||||||
//
|
//
|
||||||
@ -243,11 +242,10 @@ func ErrorCode(err error) string {
|
|||||||
return "payload_too_large"
|
return "payload_too_large"
|
||||||
case errors.Is(err, ErrConverterUnavailable):
|
case errors.Is(err, ErrConverterUnavailable):
|
||||||
return "converter_unavailable"
|
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):
|
case errors.Is(err, ErrFAAUnavailable):
|
||||||
|
// Phase 0.8b v0.6 T3 起:此 sentinel 改由 converter promote 502 file_gateway_unavailable
|
||||||
|
// 透傳(visionA 端不再直接打 FAA);對外 code 仍為 faa_unavailable,給 SRE 區分
|
||||||
|
// converter 不可達 vs converter 端 push FAA 失敗。
|
||||||
return "faa_unavailable"
|
return "faa_unavailable"
|
||||||
case errors.Is(err, ErrServiceBusy):
|
case errors.Is(err, ErrServiceBusy):
|
||||||
return "service_busy"
|
return "service_busy"
|
||||||
@ -260,9 +258,6 @@ func ErrorCode(err error) string {
|
|||||||
// Phase 0.8b:對外刻意 mask 成 converter_unavailable(不揭露「API key 不對」內部狀態);
|
// Phase 0.8b:對外刻意 mask 成 converter_unavailable(不揭露「API key 不對」內部狀態);
|
||||||
// caller 想做精細處理用 errors.Is(err, ErrConverterAuthFailed) 直接判斷(log / metric)。
|
// caller 想做精細處理用 errors.Is(err, ErrConverterAuthFailed) 直接判斷(log / metric)。
|
||||||
return "converter_unavailable"
|
return "converter_unavailable"
|
||||||
case errors.Is(err, ErrFAAAuthFailed):
|
|
||||||
// Phase 0.8b:對外刻意 mask 成 faa_unavailable,理由同上。
|
|
||||||
return "faa_unavailable"
|
|
||||||
case errors.Is(err, ErrStorageUnavailable):
|
case errors.Is(err, ErrStorageUnavailable):
|
||||||
return "storage_unavailable"
|
return "storage_unavailable"
|
||||||
case errors.Is(err, ErrModelStoreUnavailable):
|
case errors.Is(err, ErrModelStoreUnavailable):
|
||||||
@ -289,11 +284,11 @@ func HTTPStatus(err error) int {
|
|||||||
return 413
|
return 413
|
||||||
case errors.Is(err, ErrConverterUnavailable),
|
case errors.Is(err, ErrConverterUnavailable),
|
||||||
errors.Is(err, ErrFAAUnavailable),
|
errors.Is(err, ErrFAAUnavailable),
|
||||||
errors.Is(err, ErrFAAFileNotFound),
|
errors.Is(err, ErrConverterAuthFailed):
|
||||||
errors.Is(err, ErrConverterAuthFailed),
|
|
||||||
errors.Is(err, ErrFAAAuthFailed):
|
|
||||||
// Phase 0.8b:API key auth_failed 對外與「服務不可達」同層 502;
|
// Phase 0.8b:API key auth_failed 對外與「服務不可達」同層 502;
|
||||||
// 內部 log / metric 才區分(auth_failed = SRE alarm;其他 = 自然 retry)
|
// 內部 log / metric 才區分(auth_failed = SRE alarm;其他 = 自然 retry)
|
||||||
|
// v0.6 T3 後:ErrFAAFileNotFound / ErrFAAAuthFailed 已砍(visionA 端不再直接打 FAA);
|
||||||
|
// ErrFAAUnavailable 沿用、改由 converter promote 502 file_gateway_unavailable 透傳
|
||||||
return 502
|
return 502
|
||||||
case errors.Is(err, ErrStorageUnavailable), errors.Is(err, ErrModelStoreUnavailable):
|
case errors.Is(err, ErrStorageUnavailable), errors.Is(err, ErrModelStoreUnavailable):
|
||||||
// visionA 自身基礎設施問題 → 500(不是 502 gateway,因為非 upstream 失敗)
|
// visionA 自身基礎設施問題 → 500(不是 502 gateway,因為非 upstream 失敗)
|
||||||
|
|||||||
@ -26,14 +26,17 @@ func TestErrorCode(t *testing.T) {
|
|||||||
{"validation_failed", ErrValidationFailed, "validation_failed"},
|
{"validation_failed", ErrValidationFailed, "validation_failed"},
|
||||||
{"payload_too_large", ErrPayloadTooLarge, "payload_too_large"},
|
{"payload_too_large", ErrPayloadTooLarge, "payload_too_large"},
|
||||||
{"converter_unavailable", ErrConverterUnavailable, "converter_unavailable"},
|
{"converter_unavailable", ErrConverterUnavailable, "converter_unavailable"},
|
||||||
{"faa_unavailable", ErrFAAUnavailable, "faa_unavailable"},
|
// ErrFAAUnavailable:v0.6 T3 後改由 converter promote 502 file_gateway_unavailable 透傳
|
||||||
|
// (visionA 端不再直接打 FAA、但 converter→FAA push 仍可能失敗;對外 code 仍 faa_unavailable)
|
||||||
|
{"faa_unavailable_from_converter_promote", ErrFAAUnavailable, "faa_unavailable"},
|
||||||
{"service_busy", ErrServiceBusy, "service_busy"},
|
{"service_busy", ErrServiceBusy, "service_busy"},
|
||||||
// Phase 0.8b T3:以下 sentinel 已移除,不再對外暴露對應 error code
|
// Phase 0.8b T3:以下 sentinel 已移除,不再對外暴露對應 error code
|
||||||
// ErrDownloadTokenFailed / ErrMCTokenUnavailable / ErrIDPMisconfigured /
|
// ErrDownloadTokenFailed / ErrMCTokenUnavailable / ErrIDPMisconfigured /
|
||||||
// ErrIDPUnavailable / ErrServiceClientUnauthorized
|
// ErrIDPUnavailable / ErrServiceClientUnauthorized
|
||||||
// 取代:401/403 改 ErrConverterAuthFailed / ErrFAAAuthFailed(下方 wrapped)
|
// 取代:401/403 改 ErrConverterAuthFailed(下方 wrapped)
|
||||||
|
// Phase 0.8b v0.6 T3:ErrFAAAuthFailed / ErrFAAFileNotFound 已砍(visionA 端不再
|
||||||
|
// 直接打 FAA;ADR-016 撤回 FAA 直連設計、faa_client.go 整檔刪除)
|
||||||
{"converter_auth_failed_masked_as_converter_unavailable", ErrConverterAuthFailed, "converter_unavailable"},
|
{"converter_auth_failed_masked_as_converter_unavailable", ErrConverterAuthFailed, "converter_unavailable"},
|
||||||
{"faa_auth_failed_masked_as_faa_unavailable", ErrFAAAuthFailed, "faa_unavailable"},
|
|
||||||
// Reviewer M-1:visionA 自身基礎設施失敗用獨立 code(與 FAA / converter 區分)
|
// Reviewer M-1:visionA 自身基礎設施失敗用獨立 code(與 FAA / converter 區分)
|
||||||
{"storage_unavailable", ErrStorageUnavailable, "storage_unavailable"},
|
{"storage_unavailable", ErrStorageUnavailable, "storage_unavailable"},
|
||||||
{"model_store_unavailable", ErrModelStoreUnavailable, "model_store_unavailable"},
|
{"model_store_unavailable", ErrModelStoreUnavailable, "model_store_unavailable"},
|
||||||
@ -67,14 +70,15 @@ func TestHTTPStatus(t *testing.T) {
|
|||||||
{"validation_400", ErrValidationFailed, 400},
|
{"validation_400", ErrValidationFailed, 400},
|
||||||
{"payload_too_large_413", ErrPayloadTooLarge, 413},
|
{"payload_too_large_413", ErrPayloadTooLarge, 413},
|
||||||
{"converter_unavailable_502", ErrConverterUnavailable, 502},
|
{"converter_unavailable_502", ErrConverterUnavailable, 502},
|
||||||
{"faa_unavailable_502", ErrFAAUnavailable, 502},
|
// ErrFAAUnavailable:v0.6 T3 後改由 converter promote 502 file_gateway_unavailable 透傳
|
||||||
|
{"faa_unavailable_502_from_converter_promote", ErrFAAUnavailable, 502},
|
||||||
{"service_busy_503", ErrServiceBusy, 503},
|
{"service_busy_503", ErrServiceBusy, 503},
|
||||||
// Phase 0.8b T3:以下 sentinel 已移除,對外不再 mapping HTTP status
|
// Phase 0.8b T3:以下 sentinel 已移除,對外不再 mapping HTTP status
|
||||||
// ErrDownloadTokenFailed / ErrMCTokenUnavailable / ErrIDPMisconfigured /
|
// ErrDownloadTokenFailed / ErrMCTokenUnavailable / ErrIDPMisconfigured /
|
||||||
// ErrIDPUnavailable / ErrServiceClientUnauthorized
|
// ErrIDPUnavailable / ErrServiceClientUnauthorized
|
||||||
// 取代:401/403 改 ErrConverterAuthFailed / ErrFAAAuthFailed (HTTP 502)
|
// 取代:401/403 改 ErrConverterAuthFailed (HTTP 502)
|
||||||
|
// Phase 0.8b v0.6 T3:ErrFAAAuthFailed 已砍(visionA 端不再直接打 FAA)
|
||||||
{"converter_auth_failed_502", ErrConverterAuthFailed, 502},
|
{"converter_auth_failed_502", ErrConverterAuthFailed, 502},
|
||||||
{"faa_auth_failed_502", ErrFAAAuthFailed, 502},
|
|
||||||
// Reviewer M-1:visionA 自身基礎設施失敗 → 500(不是 502 gateway)
|
// Reviewer M-1:visionA 自身基礎設施失敗 → 500(不是 502 gateway)
|
||||||
{"storage_unavailable_500", ErrStorageUnavailable, 500},
|
{"storage_unavailable_500", ErrStorageUnavailable, 500},
|
||||||
{"model_store_unavailable_500", ErrModelStoreUnavailable, 500},
|
{"model_store_unavailable_500", ErrModelStoreUnavailable, 500},
|
||||||
|
|||||||
@ -1,478 +0,0 @@
|
|||||||
// FAA client — visionA-backend 對 File Access Agent 的 server-to-server HTTP client。
|
|
||||||
//
|
|
||||||
// Phase 0.8 只用 GET /files/{object_key}(給 promote-to-models 流程從 FAA pull NEF 用)。
|
|
||||||
// 其他 endpoint(PUT / DELETE / HEAD / metadata)目前 visionA 不需要,未來再補。
|
|
||||||
//
|
|
||||||
// 設計要點:
|
|
||||||
// - **Phase 0.8b 認證**:直接帶 `Authorization: Bearer <VISIONA_FAA_API_KEY>`(pre-shared
|
|
||||||
// API key),不再透過 MC OAuth client_credentials grant、不再依賴 MCTokenClient.ServiceToken()。
|
|
||||||
// 詳見 ADR-015 §3 + conversion.md §3。
|
|
||||||
// - **回 streaming body**(io.ReadCloser)— 不 io.ReadAll,避免 500MB NEF 全進 RAM
|
|
||||||
// - **Phase A retry**:dial → 拿到 response header 之間的 5xx / network / timeout 失敗
|
|
||||||
// 依 §9.1 指數退避重試 max 2 次(1s, 2s)。一旦拿到 200 response(進 Phase B:
|
|
||||||
// streaming body 給 caller),這層責任就結束 — body 中斷由 caller 處理(不可 replay)。
|
|
||||||
// 詳見下方 GetFile doc comment 的「Phase A vs Phase B retry」段。
|
|
||||||
// - 4xx → 對應 sentinel(401/403 → ErrFAAAuthFailed;404 → ErrFAAFileNotFound;
|
|
||||||
// 其他 4xx → ErrFAAUnavailable,避免新增更多 sentinel)
|
|
||||||
//
|
|
||||||
// 與 InitJob 的對比(為什麼 InitJob 不 retry 但 GetFile retry):
|
|
||||||
// - InitJob:multipart **request body** 是 streaming(io.Reader 來自上游 c.Body);
|
|
||||||
// 一旦 http.Client.Do 開始送 request body,io.Reader 已被消費,retry 無法 rewind →
|
|
||||||
// 從第一次 attempt 起就「不可重試」。
|
|
||||||
// - GetFile:GET 沒有 request body,request 完全 idempotent;retry window 涵蓋
|
|
||||||
// dial → 拿到 response header(Phase A)。Phase A 結束後(200 已到),response body
|
|
||||||
// 才是「不可 replay」的 streaming,但那不在本層責任範圍 — 本層拿到 200 就 return *FAAFile。
|
|
||||||
//
|
|
||||||
// 安全:
|
|
||||||
// - **絕不**寫 Authorization header / API key / response body 進 log
|
|
||||||
// - object_key 過長時截斷(避免 log 膨脹;FAA object_key 由 visionA 內部組,不含 user 敏感資訊)
|
|
||||||
//
|
|
||||||
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.3 / §2.6 / §9.1)
|
|
||||||
// Phase 0.8b API key 改造 (見 ADR-015 §3 + §6 + conversion.md §3)
|
|
||||||
package conversion
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// 對外 type / interface
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
// FAAClient 對 File Access Agent 的 server-to-server client。
|
|
||||||
//
|
|
||||||
// goroutine-safe:每次呼叫獨立 *http.Request;無內部 mutable state(apiKey 為 immutable 字串)。
|
|
||||||
type FAAClient interface {
|
|
||||||
// GetFile 從 FAA pull 一個 object(server-to-server,Phase 0.8b 用 pre-shared API key)。
|
|
||||||
//
|
|
||||||
// 回傳 *FAAFile.Body 是 streaming body(io.ReadCloser);**caller 必須 Close**,
|
|
||||||
// 不然底層 http.Response.Body 不會釋放、connection 也回不了 pool(goroutine + fd leak)。
|
|
||||||
// 推薦 pattern:
|
|
||||||
//
|
|
||||||
// file, err := faa.GetFile(ctx, key)
|
|
||||||
// if err != nil { return err }
|
|
||||||
// defer file.Body.Close()
|
|
||||||
// _, err = io.Copy(dst, file.Body) // streaming 寫進 visionA storage
|
|
||||||
//
|
|
||||||
// 重試行為(Phase A retry only,對齊 §9.1):
|
|
||||||
// - dial / TLS / response header 階段的 5xx / network / timeout:
|
|
||||||
// 指數退避重試 max 2 次(1s, 2s)— GET 沒 request body 完全 idempotent,可放心 retry
|
|
||||||
// - 401 / 403 / 404 / 其他 4xx:不重試,立即 return 對應 sentinel
|
|
||||||
// - ctx cancel / deadline:立即 return ctx.Err()(即使在 retry sleep 中也立即中斷)
|
|
||||||
// - 一旦拿到 200 response(進 Phase B):return *FAAFile,body 由 caller 自己讀;
|
|
||||||
// caller 在讀 body 時遇到網路中斷不再重試(streaming response 不可 replay)
|
|
||||||
//
|
|
||||||
// 錯誤映射(對齊 conversion.md §6 + errors.go):
|
|
||||||
// - ctx cancel/deadline → 透傳 ctx.Err(不包成 sentinel)
|
|
||||||
// - 401 / 403 → ErrFAAAuthFailed(Phase 0.8b 新 sentinel;對外 mask 成 faa_unavailable/502)
|
|
||||||
// - 404 → ErrFAAFileNotFound(對外 faa_unavailable/502)
|
|
||||||
// - 其他 4xx / 5xx exhausted / network exhausted → ErrFAAUnavailable(對外 faa_unavailable/502)
|
|
||||||
GetFile(ctx context.Context, objectKey string) (*FAAFile, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FAAFile 是 GetFile 成功回傳的 streaming response。
|
|
||||||
//
|
|
||||||
// **caller 必須 Body.Close()**(即使中途 error,也應 defer Close)。
|
|
||||||
type FAAFile struct {
|
|
||||||
// Body 是 streaming response body;caller 用 io.Copy 等方式 streaming 消費。
|
|
||||||
Body io.ReadCloser
|
|
||||||
|
|
||||||
// ContentLength 對應 FAA response 的 Content-Length header。
|
|
||||||
// 若 FAA 走 chunked transfer 沒帶這個 header,值為 -1(net/http 慣例)。
|
|
||||||
ContentLength int64
|
|
||||||
|
|
||||||
// ContentType 對應 FAA response 的 Content-Type header(如 "application/octet-stream")。
|
|
||||||
ContentType string
|
|
||||||
|
|
||||||
// ETag 對應 FAA response 的 ETag header(FAA 端取自 storage adapter)。
|
|
||||||
// 若 FAA 沒帶,為空字串。
|
|
||||||
ETag string
|
|
||||||
}
|
|
||||||
|
|
||||||
// FAAClientOpts 是 NewFAAClient 的依賴注入。
|
|
||||||
//
|
|
||||||
// HTTPClient / Now / Logger 為 optional(nil 自動填預設)— 方便 unit test 注入 fake。
|
|
||||||
type FAAClientOpts struct {
|
|
||||||
// BaseURL 是 FAA base URL(不帶結尾斜線)。
|
|
||||||
// 範例:http://192.168.0.130:5081
|
|
||||||
BaseURL string
|
|
||||||
|
|
||||||
// APIKey 是 Phase 0.8b 引入的 pre-shared API key(VISIONA_FAA_API_KEY)。
|
|
||||||
// 必填非空 — `NewFAAClient` 會在 APIKey 為空時 panic(fail-fast,
|
|
||||||
// 避免 server 在「未認證」狀態下啟動)。
|
|
||||||
//
|
|
||||||
// 值由 main.go 從 cfg.Conversion.FAAAPIKey(env VISIONA_FAA_API_KEY)注入;
|
|
||||||
// 與 FAA middleware 端的 FAA_API_KEY 必須對齊(rotate 時雙方同步換;FAA 端由 warrenchen 維護)。
|
|
||||||
//
|
|
||||||
// 安全:絕不 log 此值(即使前綴);Authorization header 也不 log。
|
|
||||||
//
|
|
||||||
// Phase 0.8b API key 改造 (見 ADR-015 §3 + conversion.md §3)
|
|
||||||
APIKey string
|
|
||||||
|
|
||||||
// HTTPClient 為 optional;nil 用預設(含 dial / response header timeout,但無整體 timeout)。
|
|
||||||
// 測試會注入 httptest.Server.Client()。
|
|
||||||
//
|
|
||||||
// 為什麼預設 client 不設 Timeout:
|
|
||||||
// 500MB NEF 在慢網路下 download 可能 5-10 分鐘;http.Client.Timeout 是「整體 timeout」
|
|
||||||
// 涵蓋「dial + response header + body 讀完」三段,會在大檔下載中途斷線。
|
|
||||||
// 改用 transport 層的 DialTimeout + ResponseHeaderTimeout(10s 各自)— 連線階段卡死才算 fail,
|
|
||||||
// body streaming 階段交給 ctx.Done() 控制(caller 用帶 deadline 的 ctx 即可)。
|
|
||||||
HTTPClient *http.Client
|
|
||||||
|
|
||||||
// Now 為 optional;nil 用 time.Now。測試會注入 fake clock。
|
|
||||||
Now func() time.Time
|
|
||||||
|
|
||||||
// Logger 為 optional;nil 用 slog.Default()。
|
|
||||||
Logger *slog.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// 內部固定常數
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
const (
|
|
||||||
// faaDialTimeout 是 dial 階段的 timeout(連 TCP / TLS 握手)。
|
|
||||||
// 連線一直建不起來通常是路由問題,10s 已足夠;超過視為 FAA 不可達。
|
|
||||||
faaDialTimeout = 10 * time.Second
|
|
||||||
|
|
||||||
// faaResponseHeaderTimeout 是「送完 request → 收到 response status 行」的 timeout。
|
|
||||||
// 這段是 server-side 處理時間(FAA 找檔、auth validate);10s 對小檔 metadata 階段夠寬鬆。
|
|
||||||
// 注意:這個 timeout **不涵蓋 body streaming 階段**(body streaming 由 ctx 控制)。
|
|
||||||
faaResponseHeaderTimeout = 10 * time.Second
|
|
||||||
|
|
||||||
// faaMaxRetries 是 Phase A 5xx / network / timeout 的最大重試次數(不含第一次)。
|
|
||||||
// 對齊 conversion.md §9.1:FAA GET /files/{key} max 2 retries(1s, 2s)。
|
|
||||||
faaMaxRetries = 2
|
|
||||||
|
|
||||||
// faaRetryBaseDelay 是指數退避的 base(1s, 2s)。
|
|
||||||
faaRetryBaseDelay = 1 * time.Second
|
|
||||||
|
|
||||||
// objectKeyHashLen 是 log 中 object_key 的截短後 hash 長度(前 16 hex chars)。
|
|
||||||
objectKeyHashLen = 16
|
|
||||||
|
|
||||||
// faaErrorBodyReadCap 是失敗 response 從 body 讀進 io.Discard 的最大量(4KB)。
|
|
||||||
// 失敗時讀少量 body 主要是讓 keep-alive 能 reuse connection,避免空 body 留在 pipe。
|
|
||||||
faaErrorBodyReadCap = 4 * 1024
|
|
||||||
)
|
|
||||||
|
|
||||||
// faaEndpointKind 是 log / 錯誤分類用的 endpoint 標記(目前只有一個)。
|
|
||||||
const faaEndpointKind = "faa_get_file"
|
|
||||||
|
|
||||||
// ErrFAAAPIKeyNotConfigured 啟動時 API key 為空 — 應在 NewFAAClient 立即 panic、
|
|
||||||
// 不要等到第一個 request 才發現「未認證」狀態跑進 prod。
|
|
||||||
//
|
|
||||||
// Phase 0.8b API key 改造 (見 ADR-015 §3.5.3 部署檢查清單 #1)
|
|
||||||
var ErrFAAAPIKeyNotConfigured = errors.New("conversion/faa_client: APIKey is required (set VISIONA_FAA_API_KEY)")
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// 構造 + 內部實作
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
// faaClient 是 FAAClient 的預設實作。
|
|
||||||
//
|
|
||||||
// 套件內 unexported struct(caller 拿 interface),讓未來換實作不影響 caller。
|
|
||||||
type faaClient struct {
|
|
||||||
baseURL string
|
|
||||||
apiKey string // Phase 0.8b:pre-shared API key,建構時 fail-fast 不允許空字串
|
|
||||||
http *http.Client
|
|
||||||
now func() time.Time
|
|
||||||
logger *slog.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFAAClient 建立一個 FAAClient 實例。
|
|
||||||
//
|
|
||||||
// 必填:BaseURL / APIKey。其他 optional。
|
|
||||||
// 注意:constructor 不會驗 BaseURL 連線,第一次 GetFile 才會打網路。
|
|
||||||
//
|
|
||||||
// **Fail-fast**:若 opts.APIKey 為空字串,此函式 panic。理由是 Phase 0.8b 不允許 server 在
|
|
||||||
// 「未認證」狀態下啟動 — 對齊 ADR-015 §3.5.3 部署檢查清單 #1。
|
|
||||||
//
|
|
||||||
// `opts.Tokens` 是 Phase 0.8 廢棄欄位(見 FAAClientOpts.Tokens 註解),即使非 nil 也不被
|
|
||||||
// 內部使用;T5 切換 wire 點後從 struct 移除。
|
|
||||||
func NewFAAClient(opts FAAClientOpts) FAAClient {
|
|
||||||
if opts.APIKey == "" {
|
|
||||||
panic(ErrFAAAPIKeyNotConfigured)
|
|
||||||
}
|
|
||||||
httpClient := opts.HTTPClient
|
|
||||||
if httpClient == nil {
|
|
||||||
httpClient = newDefaultFAAHTTPClient()
|
|
||||||
}
|
|
||||||
now := opts.Now
|
|
||||||
if now == nil {
|
|
||||||
now = time.Now
|
|
||||||
}
|
|
||||||
logger := opts.Logger
|
|
||||||
if logger == nil {
|
|
||||||
logger = slog.Default()
|
|
||||||
}
|
|
||||||
return &faaClient{
|
|
||||||
baseURL: strings.TrimRight(opts.BaseURL, "/"),
|
|
||||||
apiKey: opts.APIKey,
|
|
||||||
http: httpClient,
|
|
||||||
now: now,
|
|
||||||
logger: logger,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newDefaultFAAHTTPClient 建一個適合 streaming download 的預設 http.Client。
|
|
||||||
//
|
|
||||||
// 為什麼自訂 transport:
|
|
||||||
// - http.Client.Timeout 不適用大檔下載(會中斷 body streaming)
|
|
||||||
// - 需要分別控制 dial / response header timeout,body streaming 不限制(由 ctx 控)
|
|
||||||
//
|
|
||||||
// transport 其餘參數沿用 net/http DefaultTransport 的合理預設(MaxIdleConns 等)。
|
|
||||||
func newDefaultFAAHTTPClient() *http.Client {
|
|
||||||
transport := &http.Transport{
|
|
||||||
DialContext: (&net.Dialer{
|
|
||||||
Timeout: faaDialTimeout,
|
|
||||||
KeepAlive: 30 * time.Second,
|
|
||||||
}).DialContext,
|
|
||||||
ResponseHeaderTimeout: faaResponseHeaderTimeout,
|
|
||||||
// 沿用 DefaultTransport 的合理預設
|
|
||||||
MaxIdleConns: 100,
|
|
||||||
MaxIdleConnsPerHost: 10,
|
|
||||||
IdleConnTimeout: 90 * time.Second,
|
|
||||||
TLSHandshakeTimeout: 10 * time.Second,
|
|
||||||
ExpectContinueTimeout: 1 * time.Second,
|
|
||||||
}
|
|
||||||
return &http.Client{
|
|
||||||
Transport: transport,
|
|
||||||
// **不設 Timeout** — body streaming 階段由 ctx 控制
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// GetFile — Phase A retry,Phase B 不 retry 的 streaming pull
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
// GetFile 實作 FAAClient.GetFile。
|
|
||||||
//
|
|
||||||
// 流程(Phase 0.8b):
|
|
||||||
// 1. 組 URL + 建 request(直接帶 c.apiKey 進 Authorization header;不再透過 MCTokenClient)
|
|
||||||
// 2. doWithRetry:max (1 + faaMaxRetries) attempts;每 attempt 重新 c.http.Do
|
|
||||||
// - 拿到 200:直接 return *FAAFile(不 close body)
|
|
||||||
// - 拿到 4xx:close body 後依 status mapping 對應 sentinel,不 retry
|
|
||||||
// - 拿到 5xx:close body,等 backoff 後 retry
|
|
||||||
// - network / dial / responseHeader timeout:等 backoff 後 retry
|
|
||||||
// - ctx cancel / deadline:立即 return ctx.Err()
|
|
||||||
func (c *faaClient) GetFile(ctx context.Context, objectKey string) (*FAAFile, error) {
|
|
||||||
if objectKey == "" {
|
|
||||||
return nil, fmt.Errorf("conversion/faa_client: object_key is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
keyHash := hashObjectKey(objectKey)
|
|
||||||
|
|
||||||
// 1. 組 endpoint。注意 FAA 的 object_key 可能含路徑分隔符(如 "tenant/jobs/abc/output.nef")—
|
|
||||||
// 用 ResolveReference 處理;net/http 內部會做 path escape,避免 "../" 等問題。
|
|
||||||
endpoint, err := c.buildFileURL(objectKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: build faa url: %v", ErrFAAUnavailable, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 進 retry loop(Phase A only);apiKey 在 doWithRetry 內 set header
|
|
||||||
return c.doWithRetry(ctx, keyHash, endpoint)
|
|
||||||
}
|
|
||||||
|
|
||||||
// doWithRetry 是 GetFile 的 Phase A retry 執行器。
|
|
||||||
//
|
|
||||||
// Phase 0.8b 變更:
|
|
||||||
// - 不再接收 token 參數(API key 改造後 c.apiKey 直接 set header)
|
|
||||||
//
|
|
||||||
// 與 converter_client.doWithRetry 結構類似,差異:
|
|
||||||
// - 成功路徑回傳 *FAAFile(含未 close 的 streaming body),不是 []byte
|
|
||||||
// - 沒有「每次 attempt 重新建 request」需求 — GET 沒 body,request 物件可重用,
|
|
||||||
// 但為了讓 ctx-aware 行為一致(ctx cancel 後不重用舊 request),這裡每次都新建一個
|
|
||||||
func (c *faaClient) doWithRetry(
|
|
||||||
ctx context.Context,
|
|
||||||
keyHash, endpoint string,
|
|
||||||
) (*FAAFile, error) {
|
|
||||||
var lastErr error
|
|
||||||
for attempt := 0; attempt <= faaMaxRetries; attempt++ {
|
|
||||||
// retry 前等待退避;ctx cancel 立即中斷
|
|
||||||
if attempt > 0 {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
// ctx cancel/deadline → 立即 return(不 retry,不包成 sentinel)
|
|
||||||
return nil, ctx.Err()
|
|
||||||
case <-time.After(faaRetryBackoff(attempt)):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
||||||
if err != nil {
|
|
||||||
// 建 request 失敗(極罕見:URL parse 異常)— 不可 retry
|
|
||||||
return nil, fmt.Errorf("%w: build faa request: %v", ErrFAAUnavailable, err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Accept", "application/octet-stream")
|
|
||||||
// Phase 0.8b:直接帶 pre-shared API key(不查 cache、不打 MC)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
||||||
|
|
||||||
file, classifiedErr, retryable := c.doOnce(req, keyHash, attempt)
|
|
||||||
if classifiedErr == nil {
|
|
||||||
// 成功 — file 含未 close 的 body,由 caller 接手
|
|
||||||
return file, nil
|
|
||||||
}
|
|
||||||
lastErr = classifiedErr
|
|
||||||
if !retryable {
|
|
||||||
// 4xx / 401-403 / 404 / ctx cancel:直接 return,不再 retry
|
|
||||||
return nil, classifiedErr
|
|
||||||
}
|
|
||||||
// retryable 5xx / network / timeout:繼續下一輪
|
|
||||||
}
|
|
||||||
// 用完 retry 額度
|
|
||||||
c.logger.Warn("conversion.faa.retry_exhausted",
|
|
||||||
slog.String("endpoint", faaEndpointKind),
|
|
||||||
slog.String("object_key_hash", keyHash),
|
|
||||||
slog.Int("attempts", faaMaxRetries+1))
|
|
||||||
return nil, lastErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// doOnce 執行一次 Phase A:發 request → 等 response header → 分類結果。
|
|
||||||
//
|
|
||||||
// 回傳:
|
|
||||||
// - 成功(2xx):file != nil(含未 close 的 streaming body), classifiedErr=nil, retryable=false
|
|
||||||
// - 失敗:file=nil, classifiedErr 為 sentinel-wrapped error, retryable 表示是否該重試
|
|
||||||
//
|
|
||||||
// 重要:成功時 caller(doWithRetry)會直接把 file 透傳出去 — 這層**不 close body**。
|
|
||||||
// 失敗時這層**會 close body**(讀少量讓 keep-alive reuse connection)。
|
|
||||||
func (c *faaClient) doOnce(
|
|
||||||
req *http.Request,
|
|
||||||
keyHash string,
|
|
||||||
attempt int,
|
|
||||||
) (file *FAAFile, err error, retryable bool) {
|
|
||||||
startedAt := c.now()
|
|
||||||
res, doErr := c.http.Do(req)
|
|
||||||
duration := c.now().Sub(startedAt)
|
|
||||||
if doErr != nil {
|
|
||||||
// network / dial / response header timeout / ctx cancel
|
|
||||||
if errors.Is(doErr, context.Canceled) || errors.Is(doErr, context.DeadlineExceeded) {
|
|
||||||
c.logger.Warn("conversion.faa.ctx_cancelled",
|
|
||||||
slog.String("endpoint", faaEndpointKind),
|
|
||||||
slog.String("object_key_hash", keyHash),
|
|
||||||
slog.Int("attempt", attempt+1),
|
|
||||||
slog.Duration("duration", duration))
|
|
||||||
return nil, doErr, false
|
|
||||||
}
|
|
||||||
c.logger.Warn("conversion.faa.network_error",
|
|
||||||
slog.String("endpoint", faaEndpointKind),
|
|
||||||
slog.String("object_key_hash", keyHash),
|
|
||||||
slog.Int("attempt", attempt+1),
|
|
||||||
slog.Duration("duration", duration),
|
|
||||||
// err.Error() 不會含 secret(http.Client 錯誤訊息只有 URL + 連線層 errno),
|
|
||||||
// 但仍 truncate 防 log 爆量
|
|
||||||
slog.String("err", truncate(doErr.Error(), 200)))
|
|
||||||
return nil, fmt.Errorf("%w: faa network error: %v", ErrFAAUnavailable, doErr), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 成功(2xx):直接把 res.Body 透傳給 caller streaming 消費 — **不在這裡 close**!
|
|
||||||
// 注意:成功路徑沒 defer res.Body.Close() — body 的所有權交給 *FAAFile.Body。
|
|
||||||
if res.StatusCode >= 200 && res.StatusCode < 300 {
|
|
||||||
c.logger.Info("conversion.faa.get_success",
|
|
||||||
slog.String("endpoint", faaEndpointKind),
|
|
||||||
slog.String("object_key_hash", keyHash),
|
|
||||||
slog.Int("status", res.StatusCode),
|
|
||||||
slog.Int("attempt", attempt+1),
|
|
||||||
slog.Int64("content_length", res.ContentLength),
|
|
||||||
slog.Duration("duration", duration))
|
|
||||||
return &FAAFile{
|
|
||||||
Body: res.Body, // caller 責任 Close
|
|
||||||
ContentLength: res.ContentLength,
|
|
||||||
ContentType: res.Header.Get("Content-Type"),
|
|
||||||
ETag: res.Header.Get("ETag"),
|
|
||||||
}, nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 失敗(非 2xx):讀少量 body 做 log(避免 5xx 帶大 body 爆 log),然後 close
|
|
||||||
// 讀進 io.Discard 而不是真的存下來:
|
|
||||||
// - 不寫進 log(FAA 錯誤 body 可能含 requestId / 路徑等內部資訊)
|
|
||||||
// - 只是讓 keep-alive 能 reuse connection(read-to-EOF or close)
|
|
||||||
defer res.Body.Close()
|
|
||||||
_, _ = io.CopyN(io.Discard, res.Body, faaErrorBodyReadCap)
|
|
||||||
|
|
||||||
c.logger.Warn("conversion.faa.endpoint_error",
|
|
||||||
slog.String("endpoint", faaEndpointKind),
|
|
||||||
slog.String("object_key_hash", keyHash),
|
|
||||||
slog.Int("status", res.StatusCode),
|
|
||||||
slog.Int("attempt", attempt+1),
|
|
||||||
slog.Duration("duration", duration))
|
|
||||||
|
|
||||||
mappedErr, isRetryable := c.mapGetFileError(res.StatusCode)
|
|
||||||
return nil, mappedErr, isRetryable
|
|
||||||
}
|
|
||||||
|
|
||||||
// mapGetFileError 把 FAA `GET /files/{key}` 的非 2xx 對應到 sentinel + 是否 retryable。
|
|
||||||
//
|
|
||||||
// Phase 0.8b 對齊 ADR-015 §3.5.2 FAA middleware:
|
|
||||||
// - 401 unauthorized → ErrFAAAuthFailed(不 retry — API key 不對齊;運維事件)
|
|
||||||
// - 403 forbidden → ErrFAAAuthFailed(不 retry)
|
|
||||||
//
|
|
||||||
// 其他 mapping(不變):
|
|
||||||
// - 404 file_not_found → ErrFAAFileNotFound(不 retry — object 不存在)
|
|
||||||
// - 400 invalid_object_key → ErrFAAUnavailable(不 retry — visionA 端 object_key 命名 bug)
|
|
||||||
// - 其他 4xx → ErrFAAUnavailable(不 retry)
|
|
||||||
// - 5xx → ErrFAAUnavailable(**可 retry**:FAA / 下游 storage 暫時失常)
|
|
||||||
func (c *faaClient) mapGetFileError(status int) (err error, retryable bool) {
|
|
||||||
switch {
|
|
||||||
case status == http.StatusUnauthorized || status == http.StatusForbidden:
|
|
||||||
return fmt.Errorf("%w: faa get file %d", ErrFAAAuthFailed, status), false
|
|
||||||
case status == http.StatusNotFound:
|
|
||||||
return fmt.Errorf("%w: faa get file %d", ErrFAAFileNotFound, status), false
|
|
||||||
case status >= 400 && status < 500:
|
|
||||||
// 400 / 其他 4xx:不可 retry
|
|
||||||
return fmt.Errorf("%w: faa get file %d", ErrFAAUnavailable, status), false
|
|
||||||
default:
|
|
||||||
// 5xx:可 retry
|
|
||||||
return fmt.Errorf("%w: faa get file %d", ErrFAAUnavailable, status), true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// faaRetryBackoff 回傳第 n 次 retry(n 從 1 開始)的等待時間。
|
|
||||||
// 1 → 1s, 2 → 2s(對齊 conversion.md §9.1)
|
|
||||||
//
|
|
||||||
// 不加 jitter — Phase 0.8 同時打 FAA 的 caller 數量有限(promote-to-models 流程是
|
|
||||||
// 序列式 per-job 觸發),併發競爭機率低;jitter 的邊際效益低。
|
|
||||||
func faaRetryBackoff(attempt int) time.Duration {
|
|
||||||
if attempt < 1 {
|
|
||||||
return faaRetryBaseDelay
|
|
||||||
}
|
|
||||||
return faaRetryBaseDelay * time.Duration(attempt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildFileURL 用 url.Parse + ResolveReference 組 GET /files/{objectKey} 的完整 URL。
|
|
||||||
//
|
|
||||||
// 為什麼用 ResolveReference 而不是 string concat:
|
|
||||||
// - object_key 可能含路徑分隔符("tenant/jobs/abc/output.nef")
|
|
||||||
// - 直接 concat 容易踩 trailing-slash / encoding 雷
|
|
||||||
// - net/url 會做必要的 percent-escape(保留 '/' 為 path separator)
|
|
||||||
func (c *faaClient) buildFileURL(objectKey string) (string, error) {
|
|
||||||
base, err := url.Parse(c.baseURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("parse base url: %w", err)
|
|
||||||
}
|
|
||||||
// 用 url.URL{Path: ...} 避免手動 escape;net/url 會處理 path encoding。
|
|
||||||
// 注意:base.Path 可能為空或結尾帶 "/",ResolveReference 會處理。
|
|
||||||
ref := &url.URL{Path: "/files/" + objectKey}
|
|
||||||
return base.ResolveReference(ref).String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// hashObjectKey 把 object_key 算 SHA-256 後取前 16 hex chars,當 log 用的穩定 hash。
|
|
||||||
//
|
|
||||||
// 為什麼不直接 log object_key:
|
|
||||||
// - object_key 可能含路徑("tenant/jobs/uuid/output.nef")— 過長
|
|
||||||
// - 目前 visionA 的 object_key 不直接含 user 敏感資訊,但保險起見統一 hash
|
|
||||||
// - 16 chars hex(64-bit)對 visionA 內部 job 數量來說碰撞機率極低,足以追蹤單一 request
|
|
||||||
func hashObjectKey(objectKey string) string {
|
|
||||||
sum := sha256.Sum256([]byte(objectKey))
|
|
||||||
return hex.EncodeToString(sum[:])[:objectKeyHashLen]
|
|
||||||
}
|
|
||||||
@ -1,606 +0,0 @@
|
|||||||
// FAA Client 單元測試。
|
|
||||||
//
|
|
||||||
// 測試策略:
|
|
||||||
// - 用 httptest.Server mock FAA 的 GET /files/{key} 端點
|
|
||||||
// - **Phase 0.8b**:直接用 string fake API key(fakeFAAAPIKey;定義在 converter_client_test.go),
|
|
||||||
// 不再注入 stub MCTokenClient
|
|
||||||
// - 用 atomic counter 驗 retry 行為(Phase A retry:max 3 attempts = 1 + 2 retries)
|
|
||||||
// - streaming 驗證用較大但合理大小(10MB)— 真 100MB 會拖慢 test runner 太多
|
|
||||||
//
|
|
||||||
// 測試範疇對應 conversion.md §9.1(FAA GET /files retry max 2 次, 1s/2s)+ ADR-015 §3 認證:
|
|
||||||
// - GetFile_Success / GetFile_Streaming / GetFile_AuthHeader
|
|
||||||
// - GetFile_404_NoRetry / GetFile_AuthFailed401 / GetFile_AuthFailed403
|
|
||||||
// - GetFile_5xx_RetryThenSuccess / GetFile_5xx_Exhausted
|
|
||||||
// - GetFile_Network_RetryThenSuccess / GetFile_Network_Exhausted
|
|
||||||
// - GetFile_ContextCancel / GetFile_ContextCancel_DuringRetry
|
|
||||||
// - GetFile_EmptyObjectKey / GetFile_400_GenericError / HashObjectKey_StableAndLength
|
|
||||||
// - NewFAAClient_Panics_When_APIKey_Empty
|
|
||||||
//
|
|
||||||
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.3 + §9.1)
|
|
||||||
// Phase 0.8b API key 改造 (見 ADR-015 §3 + §6 + conversion.md §3)
|
|
||||||
package conversion
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// FAA mock server helpers
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
// newFAAClientForTest 建立指向 mock server 的 FAAClient。
|
|
||||||
//
|
|
||||||
// Phase 0.8b:直接傳 fakeFAAAPIKey(定義在 converter_client_test.go),不再透過 MCTokenClient。
|
|
||||||
// 用較短 timeout 加速 test。注意 streaming test 不能用整體 Timeout,所以另外覆寫。
|
|
||||||
func newFAAClientForTest(t *testing.T, baseURL string) FAAClient {
|
|
||||||
t.Helper()
|
|
||||||
return NewFAAClient(FAAClientOpts{
|
|
||||||
BaseURL: baseURL,
|
|
||||||
APIKey: fakeFAAAPIKey,
|
|
||||||
HTTPClient: &http.Client{Timeout: 5 * time.Second},
|
|
||||||
Logger: silentLogger(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// 成功路徑
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
// TestGetFile_Success:mock 回 200 + binary stream,驗 ContentLength / ETag / ContentType 解析。
|
|
||||||
func TestGetFile_Success(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
payload := []byte("binary payload here")
|
|
||||||
var receivedAuth string
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
receivedAuth = r.Header.Get("Authorization")
|
|
||||||
require.Equal(t, http.MethodGet, r.Method)
|
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
w.Header().Set("ETag", "\"etag-abc-123\"")
|
|
||||||
w.Header().Set("Content-Length", "19")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write(payload)
|
|
||||||
})
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
fc := newFAAClientForTest(t, srv.URL)
|
|
||||||
file, err := fc.GetFile(context.Background(), "tenant/jobs/abc/output.nef")
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, file)
|
|
||||||
require.NotNil(t, file.Body)
|
|
||||||
t.Cleanup(func() { _ = file.Body.Close() })
|
|
||||||
|
|
||||||
assert.Equal(t, "application/octet-stream", file.ContentType)
|
|
||||||
assert.Equal(t, "\"etag-abc-123\"", file.ETag)
|
|
||||||
assert.Equal(t, int64(19), file.ContentLength)
|
|
||||||
|
|
||||||
// caller 確實能 streaming 讀到完整 body
|
|
||||||
body, readErr := io.ReadAll(file.Body)
|
|
||||||
require.NoError(t, readErr)
|
|
||||||
assert.Equal(t, payload, body)
|
|
||||||
|
|
||||||
assert.Equal(t, "Bearer "+fakeFAAAPIKey, receivedAuth,
|
|
||||||
"Phase 0.8b:必須直接帶 pre-shared API key(不經 MC token cache)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetFile_Streaming:mock 回 10MB body,confirm caller 能 streaming 讀(不 buffer 全 RAM)。
|
|
||||||
//
|
|
||||||
// 與 InitJob streaming test 對稱:用 io.LimitReader + zerosReader,確認 reader 被多次 Read
|
|
||||||
// (而非一次性全讀)。但 net/http 端 download 的 streaming 由 res.Body 提供,這裡的關鍵是:
|
|
||||||
// - faa_client 必須**不 io.ReadAll** 把 body 提前讀完
|
|
||||||
// - caller 用 io.Copy 慢慢讀時,server 端不需要先把全部 buffer 完成
|
|
||||||
func TestGetFile_Streaming(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const totalSize = int64(10 * 1024 * 1024) // 10MB
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
w.Header().Set("Content-Length", "10485760")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
// streaming write — 用 io.Copy from zerosReader(避免一次配 10MB buffer)
|
|
||||||
_, _ = io.CopyN(w, zerosReader{}, totalSize)
|
|
||||||
})
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
// streaming download 不能用 http.Client.Timeout(會中斷 body streaming)
|
|
||||||
fc := NewFAAClient(FAAClientOpts{
|
|
||||||
BaseURL: srv.URL,
|
|
||||||
APIKey: fakeFAAAPIKey,
|
|
||||||
// 這裡用無 timeout 的 client(test 自己控)
|
|
||||||
HTTPClient: &http.Client{},
|
|
||||||
Logger: silentLogger(),
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
file, err := fc.GetFile(ctx, "big.nef")
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, file)
|
|
||||||
t.Cleanup(func() { _ = file.Body.Close() })
|
|
||||||
|
|
||||||
assert.Equal(t, totalSize, file.ContentLength)
|
|
||||||
|
|
||||||
// 用 countingReader 包 file.Body — 但 countingReader 是 io.Reader,
|
|
||||||
// 這裡換成 wrap 一下:直接 io.Copy 到 io.Discard,confirm 全 download 完成。
|
|
||||||
written, copyErr := io.Copy(io.Discard, file.Body)
|
|
||||||
require.NoError(t, copyErr)
|
|
||||||
assert.Equal(t, totalSize, written, "streaming download 必須拿到完整 body")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetFile_AuthHeader:Phase 0.8b — 驗 pre-shared API key 直接帶在 Authorization header。
|
|
||||||
//
|
|
||||||
// 用客製 APIKey(與 fakeFAAAPIKey 不同的字串),確認 client 真的透傳「建構時拿到的 key」、
|
|
||||||
// 而不是 hardcode 某個常數。
|
|
||||||
func TestGetFile_AuthHeader(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const customKey = "custom-faa-key-do-not-use-in-prod-ccccccccccccccccccccccccccccc"
|
|
||||||
|
|
||||||
var receivedAuth string
|
|
||||||
var receivedAccept string
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
receivedAuth = r.Header.Get("Authorization")
|
|
||||||
receivedAccept = r.Header.Get("Accept")
|
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
fc := NewFAAClient(FAAClientOpts{
|
|
||||||
BaseURL: srv.URL,
|
|
||||||
APIKey: customKey,
|
|
||||||
HTTPClient: &http.Client{Timeout: 5 * time.Second},
|
|
||||||
Logger: silentLogger(),
|
|
||||||
})
|
|
||||||
file, err := fc.GetFile(context.Background(), "key")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer file.Body.Close()
|
|
||||||
_, _ = io.ReadAll(file.Body)
|
|
||||||
|
|
||||||
assert.Equal(t, "Bearer "+customKey, receivedAuth,
|
|
||||||
"必須透傳建構時拿到的 API key,不可 hardcode 或從別處取")
|
|
||||||
assert.Equal(t, "application/octet-stream", receivedAccept)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// 失敗映射(不 retry 類)
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
// TestGetFile_404_NoRetry:mock 回 404 → 立即 return ErrFAAFileNotFound,不 retry。
|
|
||||||
func TestGetFile_404_NoRetry(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var attempts atomic.Int32
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
attempts.Add(1)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
_, _ = w.Write([]byte(`{"error":{"code":"file_not_found","message":"File not found."}}`))
|
|
||||||
})
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
fc := newFAAClientForTest(t, srv.URL)
|
|
||||||
file, err := fc.GetFile(context.Background(), "missing.nef")
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Nil(t, file, "失敗時不應回 FAAFile(避免 caller 誤用 nil body)")
|
|
||||||
assert.True(t, errors.Is(err, ErrFAAFileNotFound),
|
|
||||||
"404 → ErrFAAFileNotFound(caller 可精細處理)")
|
|
||||||
assert.Equal(t, int32(1), attempts.Load(),
|
|
||||||
"404 不應 retry(object 不存在 retry 也沒用)")
|
|
||||||
// 對外仍應 mask 成 faa_unavailable(避免揭露 object_key 不存在)
|
|
||||||
assert.Equal(t, "faa_unavailable", ErrorCode(err))
|
|
||||||
assert.Equal(t, 502, HTTPStatus(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetFile_AuthFailed401:Phase 0.8b — mock 回 401 → 不 retry,return ErrFAAAuthFailed。
|
|
||||||
//
|
|
||||||
// 觸發情境:VISIONA_FAA_API_KEY 與 FAA 端 FAA_API_KEY 不對齊(rotate 未同步 / env 設錯)。
|
|
||||||
// 對外仍 mask 成 faa_unavailable / 502,避免洩漏「API key 不對」內部運維狀態。
|
|
||||||
func TestGetFile_AuthFailed401(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var attempts atomic.Int32
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
attempts.Add(1)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
|
||||||
})
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
fc := newFAAClientForTest(t, srv.URL)
|
|
||||||
file, err := fc.GetFile(context.Background(), "k")
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Nil(t, file)
|
|
||||||
assert.True(t, errors.Is(err, ErrFAAAuthFailed),
|
|
||||||
"Phase 0.8b:401 必須 mapping 到新 sentinel ErrFAAAuthFailed")
|
|
||||||
// Phase 0.8b T3:舊 sentinel ErrServiceClientUnauthorized 已移除,
|
|
||||||
// 改由 ErrFAAAuthFailed 接管 401/403 mapping。
|
|
||||||
assert.Equal(t, int32(1), attempts.Load(),
|
|
||||||
"401 不應 retry(API key 不對 retry 也是 401)")
|
|
||||||
// 對外仍 mask 成 faa_unavailable
|
|
||||||
assert.Equal(t, "faa_unavailable", ErrorCode(err))
|
|
||||||
assert.Equal(t, 502, HTTPStatus(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetFile_AuthFailed403:對稱 — FAA 端 403 同樣 ErrFAAAuthFailed、不 retry。
|
|
||||||
func TestGetFile_AuthFailed403(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var attempts atomic.Int32
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
attempts.Add(1)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusForbidden)
|
|
||||||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
|
||||||
})
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
fc := newFAAClientForTest(t, srv.URL)
|
|
||||||
_, err := fc.GetFile(context.Background(), "k")
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.True(t, errors.Is(err, ErrFAAAuthFailed))
|
|
||||||
assert.Equal(t, int32(1), attempts.Load(), "403 不應 retry")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetFile_400_GenericError:FAA 400(如 invalid_object_key)→ ErrFAAUnavailable,不 retry。
|
|
||||||
func TestGetFile_400_GenericError(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var attempts atomic.Int32
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
attempts.Add(1)
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
_, _ = w.Write([]byte(`{"error":{"code":"invalid_object_key"}}`))
|
|
||||||
})
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
fc := newFAAClientForTest(t, srv.URL)
|
|
||||||
_, err := fc.GetFile(context.Background(), "invalid//key")
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.True(t, errors.Is(err, ErrFAAUnavailable),
|
|
||||||
"400(非 401/403/404)→ ErrFAAUnavailable")
|
|
||||||
// 應該不會被 mis-classified 成 ErrFAAFileNotFound
|
|
||||||
assert.False(t, errors.Is(err, ErrFAAFileNotFound))
|
|
||||||
assert.Equal(t, int32(1), attempts.Load(), "400 不應 retry(visionA 端的 bug)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// Phase A retry 驗證(5xx / network)
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
// TestGetFile_5xx_RetryThenSuccess:mock 連續 500 兩次後回 200 → 共 3 次 attempt + 成功。
|
|
||||||
//
|
|
||||||
// 對齊 §9.1:max 2 retries(1s, 2s)— 1 + 2 = 3 attempts;第 3 次成功就 return。
|
|
||||||
// 注意:test 用真實 backoff(1s + 2s = 3s)— 為了驗 §9.1 退避時序,可接受。
|
|
||||||
func TestGetFile_5xx_RetryThenSuccess(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var attempts atomic.Int32
|
|
||||||
payload := []byte("recovered after retry")
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
n := attempts.Add(1)
|
|
||||||
if n < 3 {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
_, _ = w.Write([]byte(`{"error":{"code":"internal_error"}}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
w.Header().Set("Content-Length", "21")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write(payload)
|
|
||||||
})
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
fc := newFAAClientForTest(t, srv.URL)
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
file, err := fc.GetFile(context.Background(), "k")
|
|
||||||
duration := time.Since(start)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, file)
|
|
||||||
t.Cleanup(func() { _ = file.Body.Close() })
|
|
||||||
|
|
||||||
got, _ := io.ReadAll(file.Body)
|
|
||||||
assert.Equal(t, payload, got, "第 3 次成功的 body 應正確透傳")
|
|
||||||
assert.Equal(t, int32(3), attempts.Load(),
|
|
||||||
"5xx 應 retry:max 2 retries → 3 attempts")
|
|
||||||
// 驗時序:兩次 retry 退避 1s + 2s,至少花 3s(容忍輕微誤差用 ≥2.5s)
|
|
||||||
assert.GreaterOrEqual(t, duration, 2500*time.Millisecond,
|
|
||||||
"§9.1 退避序列 1s + 2s 應至少耗 2.5s")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetFile_5xx_Exhausted:mock 持續 500 → 用完 max retry 後 return ErrFAAUnavailable。
|
|
||||||
func TestGetFile_5xx_Exhausted(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var attempts atomic.Int32
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
attempts.Add(1)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
_, _ = w.Write([]byte(`{"error":{"code":"internal_error"}}`))
|
|
||||||
})
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
fc := newFAAClientForTest(t, srv.URL)
|
|
||||||
_, err := fc.GetFile(context.Background(), "k")
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.True(t, errors.Is(err, ErrFAAUnavailable),
|
|
||||||
"5xx exhausted → ErrFAAUnavailable")
|
|
||||||
assert.Equal(t, int32(faaMaxRetries+1), attempts.Load(),
|
|
||||||
"5xx 應跑滿 max retries:1 + 2 = 3 attempts")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetFile_Network_RetryThenSuccess:前 2 次 connection refused,第 3 次成功。
|
|
||||||
//
|
|
||||||
// 用 dynamic listener swap 實作:先用一個 free port 不開 listener(dial fail),
|
|
||||||
// 第 3 次 attempt 之前才 swap 到真的 mock server。實作上比較複雜 — 改用
|
|
||||||
// proxy handler 在 mock server 內部對前 N 次「立刻 hijack 後 close」模擬 dial fail
|
|
||||||
// 不行(連線已建好);改用「server 端 force-close connection 不送任何 byte」
|
|
||||||
// 來模擬 transport 層失敗。
|
|
||||||
//
|
|
||||||
// 簡化版:用一個 proxy server,前 2 次直接 hijack + close 連線(client 看到 EOF),
|
|
||||||
// 第 3 次正常回 200。
|
|
||||||
func TestGetFile_Network_RetryThenSuccess(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var attempts atomic.Int32
|
|
||||||
payload := []byte("recovered from net error")
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
n := attempts.Add(1)
|
|
||||||
if n < 3 {
|
|
||||||
// hijack + close 模擬 connection 中斷(client 端會看到 unexpected EOF / read error)
|
|
||||||
hj, ok := w.(http.Hijacker)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("server does not support hijacking")
|
|
||||||
}
|
|
||||||
conn, _, err := hj.Hijack()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("hijack failed: %v", err)
|
|
||||||
}
|
|
||||||
_ = conn.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write(payload)
|
|
||||||
})
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
fc := newFAAClientForTest(t, srv.URL)
|
|
||||||
file, err := fc.GetFile(context.Background(), "k")
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, file)
|
|
||||||
t.Cleanup(func() { _ = file.Body.Close() })
|
|
||||||
|
|
||||||
got, _ := io.ReadAll(file.Body)
|
|
||||||
assert.Equal(t, payload, got)
|
|
||||||
assert.Equal(t, int32(3), attempts.Load(),
|
|
||||||
"network error 應 retry:max 2 retries → 3 attempts 後成功")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetFile_Network_Exhausted:dial 失敗持續發生 → 用完 max retry 後 ErrFAAUnavailable。
|
|
||||||
//
|
|
||||||
// 用一個 listen 後立刻 close 的 socket 製造 connection refused(每次 attempt 都失敗)。
|
|
||||||
func TestGetFile_Network_Exhausted(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// 拿一個 free port 立刻關掉(dial 必失敗)
|
|
||||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
require.NoError(t, err)
|
|
||||||
addr := ln.Addr().String()
|
|
||||||
require.NoError(t, ln.Close())
|
|
||||||
|
|
||||||
fc := NewFAAClient(FAAClientOpts{
|
|
||||||
BaseURL: "http://" + addr,
|
|
||||||
APIKey: fakeFAAAPIKey,
|
|
||||||
// 用較短 timeout,但仍要大於 retry 退避總和(1s + 2s = 3s)— 設 10s 安全
|
|
||||||
HTTPClient: &http.Client{Timeout: 10 * time.Second},
|
|
||||||
Logger: silentLogger(),
|
|
||||||
})
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
_, err = fc.GetFile(context.Background(), "k")
|
|
||||||
duration := time.Since(start)
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.True(t, errors.Is(err, ErrFAAUnavailable),
|
|
||||||
"network exhausted → ErrFAAUnavailable")
|
|
||||||
// retry:1 + 2 retries = 3 attempts,2 次退避 = 1s + 2s = 3s 起跳
|
|
||||||
assert.GreaterOrEqual(t, duration, 2500*time.Millisecond,
|
|
||||||
"network retry 應走完 §9.1 退避序列")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// Context cancel
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
// TestGetFile_ContextCancel:caller cancel ctx → 立即 return ctx.Err()(不包成 sentinel)。
|
|
||||||
func TestGetFile_ContextCancel(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
|
|
||||||
// mock server:handler 故意 sleep(讓 ctx cancel 在 server response 前發生)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
select {
|
|
||||||
case <-r.Context().Done():
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
fc := newFAAClientForTest(t, srv.URL)
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
go func() {
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
cancel()
|
|
||||||
}()
|
|
||||||
|
|
||||||
_, err := fc.GetFile(ctx, "k")
|
|
||||||
require.Error(t, err)
|
|
||||||
// ctx cancel → 透傳 ctx.Err()(不包成 ErrFAAUnavailable)
|
|
||||||
assert.True(t,
|
|
||||||
errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded),
|
|
||||||
"ctx cancel 應透傳,不應包成 ErrFAAUnavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetFile_ContextCancel_DuringRetry:ctx cancel 發生在 retry sleep 中 → 立即中斷。
|
|
||||||
//
|
|
||||||
// 流程:
|
|
||||||
// - mock server 持續 500(觸發 retry)
|
|
||||||
// - 在第 1 次 retry 退避(1s)的中間(500ms)cancel ctx
|
|
||||||
// - 期望:GetFile 立即 return ctx.Err(),不等完 1s 退避也不繼續第 2 次 retry
|
|
||||||
func TestGetFile_ContextCancel_DuringRetry(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var attempts atomic.Int32
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
attempts.Add(1)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
_, _ = w.Write([]byte(`{"error":{"code":"internal_error"}}`))
|
|
||||||
})
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
fc := newFAAClientForTest(t, srv.URL)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
go func() {
|
|
||||||
// 等第 1 次 attempt 跑完 + 進 retry sleep 後再 cancel
|
|
||||||
// 第 1 次 attempt 約 < 100ms;第 1 次 retry 退避 1s,在 500ms cancel
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
cancel()
|
|
||||||
}()
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
_, err := fc.GetFile(ctx, "k")
|
|
||||||
duration := time.Since(start)
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.True(t,
|
|
||||||
errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded),
|
|
||||||
"retry sleep 中 cancel → 透傳 ctx.Err()")
|
|
||||||
// 應在 cancel 後立即中斷(< 1s 整體時間)— 不該等完 1s 退避或進入第 2 次 retry
|
|
||||||
assert.Less(t, duration, 900*time.Millisecond,
|
|
||||||
"ctx cancel 應立即中斷 retry sleep(不等完退避)")
|
|
||||||
// attempts 應為 1(第 1 次 attempt 後進 retry sleep 就被 cancel)
|
|
||||||
assert.Equal(t, int32(1), attempts.Load(),
|
|
||||||
"cancel 後不應再嘗試第 2 次 attempt")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// Constructor fail-fast
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
// TestNewFAAClient_Panics_When_APIKey_Empty:fail-fast 驗證 — Phase 0.8b
|
|
||||||
// 不允許 server 在「未認證」狀態下啟動,建構式必須立即 panic。
|
|
||||||
//
|
|
||||||
// 對齊 ADR-015 §3.5.3 部署檢查清單 #1。
|
|
||||||
//
|
|
||||||
// (Phase 0.8b 之前的 `TestGetFile_ServiceTokenFailure_Propagated` 已移除:
|
|
||||||
// API key 改造後 ServiceToken 不再被呼叫,「token 取不到」這個失敗路徑結構性消失,
|
|
||||||
// 原測試的前提不存在;對應的失敗模式變成「建構時 fail-fast」由本測試覆蓋。)
|
|
||||||
func TestNewFAAClient_Panics_When_APIKey_Empty(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
r := recover()
|
|
||||||
require.NotNil(t, r, "APIKey 為空時必須 panic(fail-fast)")
|
|
||||||
err, ok := r.(error)
|
|
||||||
require.True(t, ok, "panic value 應為 error 型別")
|
|
||||||
assert.True(t, errors.Is(err, ErrFAAAPIKeyNotConfigured),
|
|
||||||
"panic 應為 ErrFAAAPIKeyNotConfigured sentinel")
|
|
||||||
}()
|
|
||||||
|
|
||||||
_ = NewFAAClient(FAAClientOpts{
|
|
||||||
BaseURL: "http://example.com",
|
|
||||||
APIKey: "", // empty — 必須觸發 panic
|
|
||||||
Logger: silentLogger(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// 額外:empty object_key validation
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
// TestGetFile_EmptyObjectKey:保護性 validation — 空字串 object_key 應立即 fail。
|
|
||||||
func TestGetFile_EmptyObjectKey(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fc := NewFAAClient(FAAClientOpts{
|
|
||||||
BaseURL: "http://invalid",
|
|
||||||
APIKey: fakeFAAAPIKey,
|
|
||||||
Logger: silentLogger(),
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := fc.GetFile(context.Background(), "")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "object_key is required",
|
|
||||||
"empty object_key 應立即 fail(不打網路)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// hashObjectKey unit test(log 用 hash 函式的穩定性)
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
// TestHashObjectKey_StableAndLength:同 input 應產生同 output;長度固定 16。
|
|
||||||
func TestHashObjectKey_StableAndLength(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
h1 := hashObjectKey("tenant/jobs/abc/output.nef")
|
|
||||||
h2 := hashObjectKey("tenant/jobs/abc/output.nef")
|
|
||||||
h3 := hashObjectKey("tenant/jobs/xyz/output.nef")
|
|
||||||
|
|
||||||
assert.Equal(t, h1, h2, "同 object_key 應產生同 hash(log 可追蹤同一 request)")
|
|
||||||
assert.NotEqual(t, h1, h3, "不同 object_key hash 應不同")
|
|
||||||
assert.Len(t, h1, objectKeyHashLen, "hash 長度固定")
|
|
||||||
}
|
|
||||||
@ -1,7 +1,11 @@
|
|||||||
// Flow — Service interface 的具體實作(T6 整合層)。
|
// Flow — Service interface 的具體實作(T6 整合層)。
|
||||||
//
|
//
|
||||||
// 整合 T2 (mc_token_client) / T3 (converter_client) / T4 (faa_client) / T5 (ownership)
|
// 整合 converter_client / ownership 成為對 handler 暴露的單一 Service。
|
||||||
// 成為對 handler 暴露的單一 Service。對齊:
|
// 對齊:
|
||||||
|
// (Phase 0.8b v0.6 T3 起:原 T2 mc_token_client / T4 faa_client 已整檔砍除;
|
||||||
|
// 服務間 download / promote 改走 converter.GetResult,認證統一 visionA → converter API key。)
|
||||||
|
//
|
||||||
|
// 對齊:
|
||||||
// - .autoflow/04-architecture/conversion.md §2.7 整體流程協調 + §4.3.1/§4.3.2
|
// - .autoflow/04-architecture/conversion.md §2.7 整體流程協調 + §4.3.1/§4.3.2
|
||||||
// - .autoflow/04-architecture/api/api-conversion.md(5 個 endpoint 規格)
|
// - .autoflow/04-architecture/api/api-conversion.md(5 個 endpoint 規格)
|
||||||
// - .autoflow/04-architecture/adr/adr-014-conversion-integration.md
|
// - .autoflow/04-architecture/adr/adr-014-conversion-integration.md
|
||||||
@ -103,30 +107,21 @@ type Storage interface {
|
|||||||
// flow 是 Service interface 的預設實作(不對外 export,caller 拿 interface)。
|
// flow 是 Service interface 的預設實作(不對外 export,caller 拿 interface)。
|
||||||
//
|
//
|
||||||
// Phase 0.8b 變更(ADR-015 §6 / conversion.md §3):
|
// Phase 0.8b 變更(ADR-015 §6 / conversion.md §3):
|
||||||
// - 移除 mcToken:服務間認證已改 pre-shared API key(API key 內含於 ConverterClient / FAAClient)
|
// - 移除 mcToken:服務間認證已改 pre-shared API key
|
||||||
// - 移除 tenantID:MC delegated download token 機制取消,不再需要 tenant 概念
|
// - 移除 tenantID:MC delegated download token 機制取消,不再需要 tenant 概念
|
||||||
// - 移除 faaBaseURL:DownloadStream 走 faa.GetFile(FAAClient 內含 baseURL),不再自組 FAA URL
|
// - 移除 faaBaseURL:visionA 端不再自組 FAA URL
|
||||||
// - 移除 delegatedTTLSeconds:delegated download token 取消
|
// - 移除 delegatedTTLSeconds:delegated download token 取消
|
||||||
//
|
//
|
||||||
// Phase 0.8b v0.6 變更(ADR-016 / conversion.md §2.5 / §4.1):
|
// Phase 0.8b v0.6 變更(ADR-016 / conversion.md §2.5 / §4.1):
|
||||||
// - DownloadStream / PromoteToModels 改走 `converter.GetResult` 從 converter MinIO 拉 NEF stream;
|
// - DownloadStream / PromoteToModels 改走 `converter.GetResult` 從 converter MinIO 拉 NEF stream;
|
||||||
// visionA 端**不再直接呼叫 FAA**(撤回 v0.5 設計缺口)
|
// visionA 端**不再直接呼叫 FAA**(撤回 v0.5 設計缺口)
|
||||||
// - faa 欄位仍保留(T3 才砍整檔 faa_client.go),但本 struct 的 method 內部不再使用 f.faa;
|
|
||||||
// `FlowOpts.FAA` 仍是必填以維持 wire 點向後相容(T3 / T5 再清)
|
|
||||||
//
|
//
|
||||||
// **T3 預期清單**(給接手的 backend agent;reviewer s-1 補):
|
// Phase 0.8b v0.6 T3 變更(本 commit):
|
||||||
// (a) 刪 internal/conversion/faa_client.go 整檔
|
// - 砍 `faa` 欄位、`FAAClient` interface、`FlowOpts.FAA` 必填校驗(v0.5 設計缺口的最後痕跡)
|
||||||
// (b) 砍 FAAClient interface(type assertion / mock 都連帶刪)
|
// - `faa_client.go` / `faa_client_test.go` 整檔刪除
|
||||||
// (c) 砍 FlowOpts.FAA 欄位 + NewService 對 FAA 的必填校驗
|
// - e2e `mockFAA` 保留作為 regression 防護(驗 visionA 端不再直接打 FAA;ADR-016 §1 設計約束)
|
||||||
// (d) 砍 flow.faa 欄位本身
|
|
||||||
// (e) 砍 cmd/api-server/main.go wire 點對 FAAClient 的注入
|
|
||||||
// (f) 砍 cmd/api-server/conversion_e2e_test.go mockFAA + setupConversionFixture 對 FAA 的 wire;
|
|
||||||
// 保留 e2e negative assertion 路徑、改驗「wire 點不存在 FAA dependency」(編譯期靜態斷言)
|
|
||||||
// (g) 砍 internal/conversion/flow_test.go flowStubFAA + flowFixture.faa 欄位(同步 T3)
|
|
||||||
// (h) 跑 grep 確認沒有殘留 `f\.faa\.` / `FAAClient` reference(含 godoc / 註解)
|
|
||||||
type flow struct {
|
type flow struct {
|
||||||
converter ConverterClient
|
converter ConverterClient
|
||||||
faa FAAClient // v0.6:保留欄位以維持 NewService 向後相容;method 內不再使用(T3 砍)
|
|
||||||
ownership Ownership
|
ownership Ownership
|
||||||
|
|
||||||
modelStore ModelStore
|
modelStore ModelStore
|
||||||
@ -140,14 +135,17 @@ type flow struct {
|
|||||||
|
|
||||||
// FlowOpts 是 NewService 的依賴注入。
|
// FlowOpts 是 NewService 的依賴注入。
|
||||||
//
|
//
|
||||||
// 必填:Converter / FAA / Ownership / ModelStore / Storage。其他 optional(nil/0 自動填合理預設)。
|
// 必填:Converter / Ownership / ModelStore / Storage。其他 optional(nil/0 自動填合理預設)。
|
||||||
//
|
//
|
||||||
// Phase 0.8b 變更(ADR-015 §6):移除 4 個欄位 — MCToken / TenantID / FAABaseURL / DelegatedTTLSeconds,
|
// Phase 0.8b 變更(ADR-015 §6):移除 4 個欄位 — MCToken / TenantID / FAABaseURL / DelegatedTTLSeconds,
|
||||||
// 因 API key 認證鏈不再依賴 MC,且 download 改 server-side stream proxy(不需自組 FAA URL)。
|
// 因 API key 認證鏈不再依賴 MC,且 download 改 server-side stream proxy(不需自組 FAA URL)。
|
||||||
|
//
|
||||||
|
// Phase 0.8b v0.6 T3 變更(本 commit):移除 `FAA FAAClient` 欄位 — ADR-016 撤回 visionA 直接
|
||||||
|
// 呼叫 FAA 的設計後,FAAClient interface 與 faa_client.go 整檔砍除;download / promote 流程
|
||||||
|
// 改走 `converter.GetResult`(含於 Converter 欄位內)。
|
||||||
type FlowOpts struct {
|
type FlowOpts struct {
|
||||||
// 3 個 client + 1 個 ownership store(T3 / T4 / T5)
|
// 2 個 client + 1 個 ownership store
|
||||||
Converter ConverterClient
|
Converter ConverterClient
|
||||||
FAA FAAClient
|
|
||||||
Ownership Ownership
|
Ownership Ownership
|
||||||
|
|
||||||
// 既有 visionA 套件的 narrow adapter
|
// 既有 visionA 套件的 narrow adapter
|
||||||
@ -168,9 +166,6 @@ func NewService(opts FlowOpts) (Service, error) {
|
|||||||
if opts.Converter == nil {
|
if opts.Converter == nil {
|
||||||
return nil, errors.New("conversion: FlowOpts.Converter is required")
|
return nil, errors.New("conversion: FlowOpts.Converter is required")
|
||||||
}
|
}
|
||||||
if opts.FAA == nil {
|
|
||||||
return nil, errors.New("conversion: FlowOpts.FAA is required")
|
|
||||||
}
|
|
||||||
if opts.Ownership == nil {
|
if opts.Ownership == nil {
|
||||||
return nil, errors.New("conversion: FlowOpts.Ownership is required")
|
return nil, errors.New("conversion: FlowOpts.Ownership is required")
|
||||||
}
|
}
|
||||||
@ -196,7 +191,6 @@ func NewService(opts FlowOpts) (Service, error) {
|
|||||||
|
|
||||||
return &flow{
|
return &flow{
|
||||||
converter: opts.Converter,
|
converter: opts.Converter,
|
||||||
faa: opts.FAA,
|
|
||||||
ownership: opts.Ownership,
|
ownership: opts.Ownership,
|
||||||
modelStore: opts.ModelStore,
|
modelStore: opts.ModelStore,
|
||||||
storage: opts.Storage,
|
storage: opts.Storage,
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
// flow_test.go — Service interface 整合層的單元測試。
|
// flow_test.go — Service interface 整合層的單元測試。
|
||||||
//
|
//
|
||||||
// 測試策略:
|
// 測試策略:
|
||||||
// - 各 client 用 in-package stub(不耦合 T3 / T4 / T5 真實邏輯,純驗 flow 整合行為)
|
// - 各 client 用 in-package stub(不耦合 ConverterClient / Ownership 真實邏輯,純驗 flow 整合行為)
|
||||||
// - 沿用 ownership_test.go 的 stubConverterClient(補上 InitJob/GetJob/Promote 實作)
|
// - 沿用 ownership_test.go 的 stubConverterClient(補上 InitJob/GetJob/Promote 實作)
|
||||||
// - 用本檔案專屬的 stubFAAClient / stubModelStore / stubStorage
|
// - 用本檔案專屬的 stubModelStore / stubStorage
|
||||||
//
|
//
|
||||||
// 涵蓋 5 個 method × happy / ownership 失敗 / client 失敗 propagation +
|
// 涵蓋 5 個 method × happy / ownership 失敗 / client 失敗 propagation +
|
||||||
// task spec 額外要求:
|
// task spec 額外要求:
|
||||||
@ -18,10 +18,8 @@
|
|||||||
// (見 ADR-015 §6 + conversion.md §3 / §4.1)
|
// (見 ADR-015 §6 + conversion.md §3 / §4.1)
|
||||||
// Phase 0.8b v0.6 T2:DownloadStream / PromoteToModels 改走 converter.GetResult
|
// Phase 0.8b v0.6 T2:DownloadStream / PromoteToModels 改走 converter.GetResult
|
||||||
// (見 ADR-016 + conversion.md §2.5 / §4.1 / §6)
|
// (見 ADR-016 + conversion.md §2.5 / §4.1 / §6)
|
||||||
// - flowStubFAA struct **保留**(T3 才整檔砍 faa_client.go)但 test 不再透過它走
|
// Phase 0.8b v0.6 T3:flowStubFAA + flowFixture.faa 欄位整段砍除(ADR-016 撤回 FAA 直連、
|
||||||
// download / promote 路徑;改用 flowStubConverter.getResultFunc hook
|
// faa_client.go 整檔刪除);FlowOpts.FAA 必填校驗一併移除。
|
||||||
// - fixture 自動安裝 default getResultFunc(回 defaultStubNEFBody)讓既有 happy-path
|
|
||||||
// test 不必個別設 hook;需 override 行為的 test 直接覆寫 fix.converter.getResultFunc
|
|
||||||
package conversion
|
package conversion
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -168,39 +166,10 @@ func (s *flowStubConverter) GetResult(ctx context.Context, jobID string) (io.Rea
|
|||||||
|
|
||||||
var _ ConverterClient = (*flowStubConverter)(nil)
|
var _ ConverterClient = (*flowStubConverter)(nil)
|
||||||
|
|
||||||
// flowStubFAA 是 FAAClient stub。
|
|
||||||
type flowStubFAA struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
getFileFunc func(ctx context.Context, objectKey string) (*FAAFile, error)
|
|
||||||
getCalls atomic.Int32
|
|
||||||
lastKey string
|
|
||||||
}
|
|
||||||
|
|
||||||
func newFlowStubFAA() *flowStubFAA {
|
|
||||||
return &flowStubFAA{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *flowStubFAA) GetFile(ctx context.Context, objectKey string) (*FAAFile, error) {
|
|
||||||
s.getCalls.Add(1)
|
|
||||||
s.mu.Lock()
|
|
||||||
s.lastKey = objectKey
|
|
||||||
s.mu.Unlock()
|
|
||||||
if s.getFileFunc != nil {
|
|
||||||
return s.getFileFunc(ctx, objectKey)
|
|
||||||
}
|
|
||||||
body := io.NopCloser(strings.NewReader("nef-bytes-stub"))
|
|
||||||
return &FAAFile{
|
|
||||||
Body: body,
|
|
||||||
ContentLength: int64(len("nef-bytes-stub")),
|
|
||||||
ContentType: "application/octet-stream",
|
|
||||||
ETag: "stub-etag",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ FAAClient = (*flowStubFAA)(nil)
|
|
||||||
|
|
||||||
// Phase 0.8b T4:原 flowStubMCToken 已整段刪除(MC 認證鏈取消、flow 不再依賴 MCTokenClient)。
|
// Phase 0.8b T4:原 flowStubMCToken 已整段刪除(MC 認證鏈取消、flow 不再依賴 MCTokenClient)。
|
||||||
// Phase 0.8b T5:mc_token_stub.go 整檔砍除;MCTokenClient interface 已不存在。
|
// Phase 0.8b T5:mc_token_stub.go 整檔砍除;MCTokenClient interface 已不存在。
|
||||||
|
// Phase 0.8b v0.6 T3:flowStubFAA 整段砍除(ADR-016 撤回 FAA 直連;FAAClient interface
|
||||||
|
// + faa_client.go 整檔已刪);download / promote 改驗 converter.GetResult。
|
||||||
|
|
||||||
// flowStubModelStore 是 ModelStore stub。
|
// flowStubModelStore 是 ModelStore stub。
|
||||||
type flowStubModelStore struct {
|
type flowStubModelStore struct {
|
||||||
@ -299,7 +268,6 @@ var _ Storage = (*flowStubStorage)(nil)
|
|||||||
type flowFixture struct {
|
type flowFixture struct {
|
||||||
svc Service
|
svc Service
|
||||||
converter *flowStubConverter
|
converter *flowStubConverter
|
||||||
faa *flowStubFAA
|
|
||||||
models *flowStubModelStore
|
models *flowStubModelStore
|
||||||
storage *flowStubStorage
|
storage *flowStubStorage
|
||||||
ownership Ownership
|
ownership Ownership
|
||||||
@ -313,15 +281,12 @@ type flowFixture struct {
|
|||||||
// 個別設 hook。需要 override 行為(specific Content-Length / error)的 test 直接覆寫
|
// 個別設 hook。需要 override 行為(specific Content-Length / error)的 test 直接覆寫
|
||||||
// `fix.converter.getResultFunc` 即可。
|
// `fix.converter.getResultFunc` 即可。
|
||||||
//
|
//
|
||||||
// **Phase 0.8b v0.6 (T2)** — FAA 欄位/stub 過渡狀態:FAA 欄位保留作為 T3 過渡(method 內無 caller、
|
// **Phase 0.8b v0.6 T3**:FAA 欄位/stub 整段砍除(ADR-016 撤回 FAA 直連)。FlowOpts.FAA 必填
|
||||||
// godoc flow.go:111-118 明示「T3 砍」);`flowStubFAA` 仍保留並維持 wire 進 FlowOpts.FAA,以
|
// 校驗一併移除;e2e negative assertion 仍由 conversion_e2e_test.go 端 mockFAA + getCallCount
|
||||||
// 驗 e2e negative assertion「FAA 0 命中」(conversion_e2e_test.go:1037-1040)。T3 砍
|
// 保留作為 regression 防護(驗 visionA 端不再直接打 FAA)。
|
||||||
// faa_client.go 整檔時同步砍:(a) `flowStubFAA` type;(b) `flowFixture.faa` 欄位;
|
|
||||||
// (c) FlowOpts.FAA 必填;(d) e2e mockFAA + setupConversionFixture 對 FAA 的 wire。
|
|
||||||
func newFlowFixture(t *testing.T) *flowFixture {
|
func newFlowFixture(t *testing.T) *flowFixture {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
conv := newFlowStubConverter()
|
conv := newFlowStubConverter()
|
||||||
faa := newFlowStubFAA()
|
|
||||||
models := newFlowStubModelStore()
|
models := newFlowStubModelStore()
|
||||||
storage := newFlowStubStorage()
|
storage := newFlowStubStorage()
|
||||||
own := NewOwnership(conv, newSilentLogger())
|
own := NewOwnership(conv, newSilentLogger())
|
||||||
@ -338,7 +303,6 @@ func newFlowFixture(t *testing.T) *flowFixture {
|
|||||||
|
|
||||||
svc, err := NewService(FlowOpts{
|
svc, err := NewService(FlowOpts{
|
||||||
Converter: conv,
|
Converter: conv,
|
||||||
FAA: faa,
|
|
||||||
Ownership: own,
|
Ownership: own,
|
||||||
ModelStore: models,
|
ModelStore: models,
|
||||||
Storage: storage,
|
Storage: storage,
|
||||||
@ -351,7 +315,6 @@ func newFlowFixture(t *testing.T) *flowFixture {
|
|||||||
return &flowFixture{
|
return &flowFixture{
|
||||||
svc: svc,
|
svc: svc,
|
||||||
converter: conv,
|
converter: conv,
|
||||||
faa: faa,
|
|
||||||
models: models,
|
models: models,
|
||||||
storage: storage,
|
storage: storage,
|
||||||
ownership: own,
|
ownership: own,
|
||||||
@ -387,12 +350,12 @@ func makeMultipartBody(t *testing.T, clientUserID string) (body io.Reader, conte
|
|||||||
// Constructor — 缺欄位驗證
|
// Constructor — 缺欄位驗證
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
// Phase 0.8b T4:TenantID / FAABaseURL / MCToken 欄位已從 FlowOpts 砍除;
|
// Phase 0.8b T4:TenantID / FAABaseURL / MCToken 欄位已從 FlowOpts 砍除。
|
||||||
// 必填欄位降為 5 個(Converter / FAA / Ownership / ModelStore / Storage)。
|
// Phase 0.8b v0.6 T3:FAA 欄位一併砍除(ADR-016);必填欄位降為 4 個
|
||||||
|
// (Converter / Ownership / ModelStore / Storage)。
|
||||||
func TestNewService_RequiredFields(t *testing.T) {
|
func TestNewService_RequiredFields(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
conv := newFlowStubConverter()
|
conv := newFlowStubConverter()
|
||||||
faa := newFlowStubFAA()
|
|
||||||
own := NewOwnership(conv, newSilentLogger())
|
own := NewOwnership(conv, newSilentLogger())
|
||||||
mod := newFlowStubModelStore()
|
mod := newFlowStubModelStore()
|
||||||
st := newFlowStubStorage()
|
st := newFlowStubStorage()
|
||||||
@ -401,11 +364,10 @@ func TestNewService_RequiredFields(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
opts FlowOpts
|
opts FlowOpts
|
||||||
}{
|
}{
|
||||||
{"missing converter", FlowOpts{FAA: faa, Ownership: own, ModelStore: mod, Storage: st}},
|
{"missing converter", FlowOpts{Ownership: own, ModelStore: mod, Storage: st}},
|
||||||
{"missing faa", FlowOpts{Converter: conv, Ownership: own, ModelStore: mod, Storage: st}},
|
{"missing ownership", FlowOpts{Converter: conv, ModelStore: mod, Storage: st}},
|
||||||
{"missing ownership", FlowOpts{Converter: conv, FAA: faa, ModelStore: mod, Storage: st}},
|
{"missing modelstore", FlowOpts{Converter: conv, Ownership: own, Storage: st}},
|
||||||
{"missing modelstore", FlowOpts{Converter: conv, FAA: faa, Ownership: own, Storage: st}},
|
{"missing storage", FlowOpts{Converter: conv, Ownership: own, ModelStore: mod}},
|
||||||
{"missing storage", FlowOpts{Converter: conv, FAA: faa, Ownership: own, ModelStore: mod}},
|
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
tt := tt
|
tt := tt
|
||||||
@ -420,13 +382,12 @@ func TestNewService_RequiredFields(t *testing.T) {
|
|||||||
func TestNewService_DefaultsApplied(t *testing.T) {
|
func TestNewService_DefaultsApplied(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
conv := newFlowStubConverter()
|
conv := newFlowStubConverter()
|
||||||
faa := newFlowStubFAA()
|
|
||||||
own := NewOwnership(conv, newSilentLogger())
|
own := NewOwnership(conv, newSilentLogger())
|
||||||
mod := newFlowStubModelStore()
|
mod := newFlowStubModelStore()
|
||||||
st := newFlowStubStorage()
|
st := newFlowStubStorage()
|
||||||
|
|
||||||
svc, err := NewService(FlowOpts{
|
svc, err := NewService(FlowOpts{
|
||||||
Converter: conv, FAA: faa, Ownership: own,
|
Converter: conv, Ownership: own,
|
||||||
ModelStore: mod, Storage: st,
|
ModelStore: mod, Storage: st,
|
||||||
// DefaultJobExpiryDuration 留空 → 應 fallback 7d
|
// DefaultJobExpiryDuration 留空 → 應 fallback 7d
|
||||||
})
|
})
|
||||||
@ -894,8 +855,9 @@ func TestPromoteToModels_HappyPath(t *testing.T) {
|
|||||||
assert.Equal(t, int32(1), fix.converter.promoteCalls.Load())
|
assert.Equal(t, int32(1), fix.converter.promoteCalls.Load())
|
||||||
assert.Equal(t, int32(1), fix.converter.getResultCalls.Load(),
|
assert.Equal(t, int32(1), fix.converter.getResultCalls.Load(),
|
||||||
"PromoteToModels 應呼叫 1 次 converter.GetResult(v0.6 取代 faa.GetFile)")
|
"PromoteToModels 應呼叫 1 次 converter.GetResult(v0.6 取代 faa.GetFile)")
|
||||||
assert.Equal(t, int32(0), fix.faa.getCalls.Load(),
|
// v0.6 T3:FAAClient interface 已整檔砍除(faa_client.go 不存在);
|
||||||
"v0.6:visionA 端不再直接打 FAA,faa.GetFile 不該被呼叫")
|
// 「visionA 端不再直接打 FAA」改由「型別已不存在」的編譯期保證 + e2e mockFAA 端 negative
|
||||||
|
// assertion 雙重防護(conversion_e2e_test.go:TestConversionE2E_DownloadStream)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestPromoteToModels_DefaultName:caller 傳空 name 應走 fallback `<stem>_kl<chip>`。
|
// TestPromoteToModels_DefaultName:caller 傳空 name 應走 fallback `<stem>_kl<chip>`。
|
||||||
@ -1007,8 +969,8 @@ func TestPromoteToModels_ConverterGetResultError_Propagation(t *testing.T) {
|
|||||||
rec, _ := fix.models.FindBySourceJobID(context.Background(), "user-alice", "j1")
|
rec, _ := fix.models.FindBySourceJobID(context.Background(), "user-alice", "j1")
|
||||||
assert.Nil(t, rec)
|
assert.Nil(t, rec)
|
||||||
|
|
||||||
// v0.6:visionA 端不再直接打 FAA
|
// v0.6 T3:FAAClient 已整檔砍除(編譯期保證 visionA 端不再直接打 FAA);
|
||||||
assert.Equal(t, int32(0), fix.faa.getCalls.Load())
|
// 不再有 fix.faa.getCalls assertion 需要驗
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestPromoteToModels_ConverterGetResultExpired_Propagation:v0.6 新增——
|
// TestPromoteToModels_ConverterGetResultExpired_Propagation:v0.6 新增——
|
||||||
@ -1060,11 +1022,10 @@ func TestPromoteToModels_StorageError(t *testing.T) {
|
|||||||
_, err := fix.svc.PromoteToModels(context.Background(), "user-alice", "j1", "x")
|
_, err := fix.svc.PromoteToModels(context.Background(), "user-alice", "j1", "x")
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.True(t, errors.Is(err, ErrStorageUnavailable),
|
assert.True(t, errors.Is(err, ErrStorageUnavailable),
|
||||||
"storage.Put 失敗應歸類為 ErrStorageUnavailable,不是 ErrFAAUnavailable")
|
"storage.Put 失敗應歸類為 ErrStorageUnavailable,不是 ErrConverterUnavailable")
|
||||||
// 確認沒被誤包成其他 sentinel
|
// 確認沒被誤包成其他 sentinel
|
||||||
assert.False(t, errors.Is(err, ErrFAAUnavailable),
|
assert.False(t, errors.Is(err, ErrConverterUnavailable),
|
||||||
"storage 失敗不該被歸類為 FAA 問題(Reviewer M-1)")
|
"storage 失敗不該被歸類為 converter 問題(Reviewer M-1)")
|
||||||
assert.False(t, errors.Is(err, ErrConverterUnavailable))
|
|
||||||
|
|
||||||
// model record 不應被建(storage 失敗在 modelStore.Save 前)
|
// model record 不應被建(storage 失敗在 modelStore.Save 前)
|
||||||
rec, _ := fix.models.FindBySourceJobID(context.Background(), "user-alice", "j1")
|
rec, _ := fix.models.FindBySourceJobID(context.Background(), "user-alice", "j1")
|
||||||
@ -1103,13 +1064,18 @@ func TestPromoteToModels_ModelStoreError(t *testing.T) {
|
|||||||
// - 不再依賴 MCTokenClient(flowStubMCToken 已整段刪除)
|
// - 不再依賴 MCTokenClient(flowStubMCToken 已整段刪除)
|
||||||
// - 對外仍由同一個 GET /api/conversion/{job_id}/download endpoint 觸發(handler 層改 stream proxy)
|
// - 對外仍由同一個 GET /api/conversion/{job_id}/download endpoint 觸發(handler 層改 stream proxy)
|
||||||
//
|
//
|
||||||
// 測試 case 對齊原 6 個 happy / ownership / state / error propagation 路徑:
|
// 測試 case 對齊 happy / ownership / state / error propagation 路徑:
|
||||||
// 1. HappyPath:成功拉到 stream + metadata 正確
|
// 1. HappyPath:成功拉到 stream + metadata 正確
|
||||||
// 2. SpecialChars:user_id / job_id 含特殊字元時 buildTargetObjectKey 正確 + filename 安全
|
// 2. FilenameFromConverterJob:filename 取自 cj.SourceFilename + Platform
|
||||||
// 3. OwnershipMismatch:→ ErrJobNotFound
|
// 3. DefaultsContentType:converter 沒給 Content-Type 時 fallback application/octet-stream
|
||||||
// 4. JobNotCompleted:→ ErrJobNotCompleted
|
// 4. OwnershipMismatch:→ ErrJobNotFound
|
||||||
// 5. PromoteError_Propagation:promote 5xx 透傳
|
// 5. JobNotCompleted:→ ErrJobNotCompleted
|
||||||
// 6. FAAError_Propagation(取代 MCError):FAA pull 失敗透傳
|
// 6. PromoteError_Propagation:promote 5xx 透傳
|
||||||
|
// 7. ConverterGetResultError_Propagation(v0.6 取代 FAAError_Propagation)
|
||||||
|
// 8. ConverterAuthFailed_Propagation(v0.6 取代 FAAAuthFailed_Propagation)
|
||||||
|
// 9. ConverterResultExpired_Propagation(v0.6 新增;410 result_expired)
|
||||||
|
// 10. ConverterValidationFailed_Propagation(v0.6 T3 / s-3 新增)
|
||||||
|
// 11. StorageError_StreamClosed(v0.6 T3 / s-4 新增;驗 fd leak 防護)
|
||||||
|
|
||||||
// TestDownloadStream_HappyPath:成功 → 拿到 io.ReadCloser + DownloadMetadata 正確。
|
// TestDownloadStream_HappyPath:成功 → 拿到 io.ReadCloser + DownloadMetadata 正確。
|
||||||
//
|
//
|
||||||
@ -1167,9 +1133,8 @@ func TestDownloadStream_HappyPath(t *testing.T) {
|
|||||||
assert.Equal(t, int32(1), fix.converter.getResultCalls.Load(),
|
assert.Equal(t, int32(1), fix.converter.getResultCalls.Load(),
|
||||||
"DownloadStream 應呼叫 1 次 converter.GetResult")
|
"DownloadStream 應呼叫 1 次 converter.GetResult")
|
||||||
|
|
||||||
// v0.6:visionA 端不再直接打 FAA
|
// v0.6 T3:FAAClient interface + faa_client.go 已整檔砍除 → 編譯期保證「visionA 端不再
|
||||||
assert.Equal(t, int32(0), fix.faa.getCalls.Load(),
|
// 直接打 FAA」;e2e mockFAA 端的 negative assertion 提供 wire 層 regression 防護
|
||||||
"v0.6:DownloadStream 不該呼叫 faa.GetFile")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDownloadStream_FilenameFromConverterJob:filename 取自 cj.SourceFilename + Platform,
|
// TestDownloadStream_FilenameFromConverterJob:filename 取自 cj.SourceFilename + Platform,
|
||||||
@ -1236,7 +1201,6 @@ func TestDownloadStream_OwnershipMismatch(t *testing.T) {
|
|||||||
assert.Nil(t, meta)
|
assert.Nil(t, meta)
|
||||||
|
|
||||||
// converter.GetResult 不該被打到(ownership 不符在 GetResult 之前)
|
// converter.GetResult 不該被打到(ownership 不符在 GetResult 之前)
|
||||||
// v0.6:取代原 faa.getCalls assert
|
|
||||||
assert.Equal(t, int32(0), fix.converter.getResultCalls.Load(),
|
assert.Equal(t, int32(0), fix.converter.getResultCalls.Load(),
|
||||||
"ownership 不符應在 converter.GetResult 之前短路")
|
"ownership 不符應在 converter.GetResult 之前短路")
|
||||||
}
|
}
|
||||||
@ -1272,7 +1236,6 @@ func TestDownloadStream_PromoteError_Propagation(t *testing.T) {
|
|||||||
assert.True(t, errors.Is(err, ErrConverterUnavailable))
|
assert.True(t, errors.Is(err, ErrConverterUnavailable))
|
||||||
|
|
||||||
// converter.GetResult 不該被打到(promote 失敗在 GetResult 之前)
|
// converter.GetResult 不該被打到(promote 失敗在 GetResult 之前)
|
||||||
// v0.6:取代原 faa.getCalls assert
|
|
||||||
assert.Equal(t, int32(0), fix.converter.getResultCalls.Load(),
|
assert.Equal(t, int32(0), fix.converter.getResultCalls.Load(),
|
||||||
"promote 失敗應在 converter.GetResult 之前短路")
|
"promote 失敗應在 converter.GetResult 之前短路")
|
||||||
}
|
}
|
||||||
@ -1348,6 +1311,93 @@ func TestDownloadStream_ConverterResultExpired_Propagation(t *testing.T) {
|
|||||||
assert.Equal(t, 410, HTTPStatus(err))
|
assert.Equal(t, 410, HTTPStatus(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDownloadStream_ConverterValidationFailed_Propagation:v0.6 T3 s-3 補強——
|
||||||
|
// converter `GET /api/v1/jobs/{id}/result` 端回 4xx(非 401/403/404/409/410)→
|
||||||
|
// converter_client.mapGetResultError mapping 到 ErrValidationFailed;
|
||||||
|
// flow.DownloadStream 透傳;handler 層對外 HTTP 400 + code `validation_failed`。
|
||||||
|
//
|
||||||
|
// 設計動機(T1/T2 reviewer s-3 要求):mapGetResultError 內 `case status >= 400 && status < 500`
|
||||||
|
// 的 fallback 路徑沒測試覆蓋,補上避免 silent fallback regression。
|
||||||
|
func TestDownloadStream_ConverterValidationFailed_Propagation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fix := newFlowFixture(t)
|
||||||
|
|
||||||
|
fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "completed", CreatedAt: time.Now()})
|
||||||
|
fix.ownership.Set("j1", "user-alice")
|
||||||
|
fix.converter.getResultFunc = func(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error) {
|
||||||
|
// 模擬 converter 端 422 / 其他 4xx(converter_client 會收斂到 ErrValidationFailed)
|
||||||
|
return nil, nil, fmt.Errorf("%w: get_result 422", ErrValidationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t, errors.Is(err, ErrValidationFailed),
|
||||||
|
"converter 端 4xx fallback 必須透傳 ErrValidationFailed 給 handler")
|
||||||
|
assert.Nil(t, stream)
|
||||||
|
assert.Nil(t, meta)
|
||||||
|
// 對外 ErrorCode / HTTPStatus 對齊 conversion.md §6 + api-conversion.md §錯誤碼總覽
|
||||||
|
assert.Equal(t, "validation_failed", ErrorCode(err))
|
||||||
|
assert.Equal(t, 400, HTTPStatus(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// instrumentedReadCloser 是 s-4 用的 io.ReadCloser wrapper,計數 Close 被呼叫次數。
|
||||||
|
//
|
||||||
|
// 用於驗 flow.PromoteToModels 在 storage.Put 失敗時仍有 close converter.GetResult
|
||||||
|
// 回的 stream(避免 fd / goroutine leak;flow.go `defer stream.Close()` 已實作,
|
||||||
|
// 但缺乏 explicit test 驗證行為)。
|
||||||
|
type instrumentedReadCloser struct {
|
||||||
|
io.Reader
|
||||||
|
closeCalls atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *instrumentedReadCloser) Close() error {
|
||||||
|
r.closeCalls.Add(1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPromoteToModels_StorageError_StreamClosed:v0.6 T3 s-4 補強——
|
||||||
|
// PromoteToModels 第 5 步從 converter.GetResult 拿到 stream,第 6 步 storage.Put 失敗時,
|
||||||
|
// 必須仍 close 該 stream(避免 fd / goroutine leak)。
|
||||||
|
//
|
||||||
|
// 設計動機(T2 reviewer s-4 要求):flow.go:635 `defer stream.Close()` 行為缺乏 explicit
|
||||||
|
// regression 防護;本 test 用 instrumented stream wrapper 計數 Close 呼叫次數,驗值 ≥ 1。
|
||||||
|
//
|
||||||
|
// 為什麼用 ≥ 1 而不是 == 1:Go defer 行為下 stream.Close 應該被呼叫恰好 1 次,但容忍多次
|
||||||
|
// close(io.NopCloser 等 wrapper 對重複 close 是安全的);用 ≥ 1 避免測試對 close 次數
|
||||||
|
// 過度耦合(未來改寫成 defer + explicit close 的 cleanup pattern 也不破壞此 test)。
|
||||||
|
func TestPromoteToModels_StorageError_StreamClosed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fix := newFlowFixture(t)
|
||||||
|
|
||||||
|
fix.converter.setJob(&ConverterJob{
|
||||||
|
JobID: "j1", Status: "completed", CreatedAt: time.Now(),
|
||||||
|
SourceFilename: "x.onnx", Platform: "720",
|
||||||
|
})
|
||||||
|
fix.ownership.Set("j1", "user-alice")
|
||||||
|
|
||||||
|
// 注入 instrumented stream — 驗 storage.Put 失敗時仍會被 Close
|
||||||
|
instrumented := &instrumentedReadCloser{Reader: strings.NewReader("nef-bytes")}
|
||||||
|
fix.converter.getResultFunc = func(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error) {
|
||||||
|
return instrumented, &DownloadMetadata{
|
||||||
|
Filename: "x.nef",
|
||||||
|
ContentType: "application/octet-stream",
|
||||||
|
ContentLength: int64(len("nef-bytes")),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// storage.Put 設為失敗 — 觸發 ErrStorageUnavailable 並驗 stream 仍被 close
|
||||||
|
fix.storage.putErr = errors.New("disk full")
|
||||||
|
|
||||||
|
_, err := fix.svc.PromoteToModels(context.Background(), "user-alice", "j1", "x")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t, errors.Is(err, ErrStorageUnavailable),
|
||||||
|
"storage.Put 失敗應歸類為 ErrStorageUnavailable")
|
||||||
|
|
||||||
|
// **核心斷言**:即使 storage.Put 失敗,stream.Close 仍應被呼叫(flow.go defer 保護)
|
||||||
|
assert.GreaterOrEqual(t, int(instrumented.closeCalls.Load()), 1,
|
||||||
|
"storage.Put 失敗時,converter.GetResult 回的 stream 必須仍被 Close(避免 fd / goroutine leak;flow.go:635 defer 保護)")
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// helper functions tests
|
// helper functions tests
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|||||||
@ -1,13 +1,20 @@
|
|||||||
// Package conversion 內部 utility helpers。
|
// Package conversion 內部 utility helpers。
|
||||||
//
|
//
|
||||||
// 此檔收容跨檔共用的小型 helper(log truncate 等),原本散落在
|
// 此檔收容跨檔共用的小型 helper(log truncate / object key hash 等),原本散落在
|
||||||
// mc_token_client.go;Phase 0.8b T3 砍 mc_token_client 整個檔後搬到此獨立檔,
|
// mc_token_client.go / faa_client.go;Phase 0.8b T3 砍上述兩檔後搬到此獨立檔,
|
||||||
// 避免 truncate 隨 mc_token_client.go 一起被砍(仍被 converter_client.go /
|
// 避免被連帶砍除(仍被 converter_client.go / flow.go 的 log 拼接使用)。
|
||||||
// faa_client.go 的 log error 拼接使用)。
|
|
||||||
//
|
//
|
||||||
// Phase 0.8b conversion (見 ADR-015 §6 / .autoflow/04-architecture/conversion.md §2)
|
// Phase 0.8b v0.6 conversion (見 ADR-016 / docs/autoflow/04-architecture/conversion.md §2)
|
||||||
package conversion
|
package conversion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
// objectKeyHashLen 是 log 中 object_key 的截短後 hash 長度(前 16 hex chars)。
|
||||||
|
const objectKeyHashLen = 16
|
||||||
|
|
||||||
// truncate 把字串截到 max 長度(避免 log 太長)。
|
// truncate 把字串截到 max 長度(避免 log 太長)。
|
||||||
func truncate(s string, max int) string {
|
func truncate(s string, max int) string {
|
||||||
if len(s) <= max {
|
if len(s) <= max {
|
||||||
@ -15,3 +22,18 @@ func truncate(s string, max int) string {
|
|||||||
}
|
}
|
||||||
return s[:max] + "...(truncated)"
|
return s[:max] + "...(truncated)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hashObjectKey 把 object_key 算 SHA-256 後取前 16 hex chars,當 log 用的穩定 hash。
|
||||||
|
//
|
||||||
|
// 為什麼不直接 log object_key:
|
||||||
|
// - object_key 可能含路徑("tenant/jobs/uuid/output.nef")— 過長
|
||||||
|
// - 目前 visionA 的 object_key 不直接含 user 敏感資訊,但保險起見統一 hash
|
||||||
|
// - 16 chars hex(64-bit)對 visionA 內部 job 數量來說碰撞機率極低,足以追蹤單一 request
|
||||||
|
//
|
||||||
|
// Phase 0.8b v0.6(T3):原本定義在 faa_client.go;砍 faa_client.go 時搬到 util.go
|
||||||
|
// (flow.go DownloadStream / PromoteToModels 仍用 hashObjectKey 為 log 加 target_object_key
|
||||||
|
// 標記,方便跨 service 追蹤同一個 NEF 物件)。
|
||||||
|
func hashObjectKey(objectKey string) string {
|
||||||
|
sum := sha256.Sum256([]byte(objectKey))
|
||||||
|
return hex.EncodeToString(sum[:])[:objectKeyHashLen]
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user