From a6406209bd2a4a037ba5804bdaae8f78d255bbbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Thu, 29 Oct 2026 06:28:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(httpapi):=20=E6=81=A2=E5=A4=8D=E4=BD=9C?= =?UTF-8?q?=E4=B8=9A=20id=20=E6=94=B9=E6=88=90=E7=BB=B4=E6=8A=A4=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E5=8F=AF=E6=8E=A5=E5=8F=97=E7=9A=84=E5=BD=A2=E7=8A=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit librestore--<时间戳.微秒> 带 ".",冻结协调器的 job 白名单只收 [A-Za-z0-9_-],所以每次恢复都在开窗口时被拒(invalid freeze request), 作业随后被接管扫成失败。改纳秒整数,并加测试锁住该约束。 试过:make verify 15 项全绿;真实恢复复现该错误后修复。 --- CHANGELOG.md | 5 +++++ internal/httpapi/library_restore.go | 8 +++++-- internal/httpapi/library_restore_test.go | 28 ++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3e2feb..4bd8c31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,8 +36,13 @@ 目标实例之后取图/附件会指向测试环境的桶,或直接取不到。现在写入前把定位符里的桶 换成本次目标桶(键不变),解析不出来就报错而不是写一行指向别处的资源。 +- **恢复作业 id 让维护窗口拒绝**:`librestore--<时间戳.微秒>` 带 `.`,而冻结协调器 + 只接受 `[A-Za-z0-9_-]`,每次恢复都在开窗口时被拒(`invalid freeze request`),作业随后被 + 接管扫成失败。改用纳秒整数,并加测试锁住"id 必须是维护窗口可接受的标识"。 + ### Tests +- `internal/httpapi/library_restore_test.go`:恢复作业 id 必须能被维护窗口接受。 - `internal/releaseadapter/v071/resource_locator_test.go`:嵌套/单层两种定位符换桶、裸键原样、 非法形状拒绝。 - `internal/releaseadapter/v071/search_empty_index_test.go`:空索引=空基线、有文档无范围字段必须报错。 diff --git a/internal/httpapi/library_restore.go b/internal/httpapi/library_restore.go index 10f28f3..14edde6 100644 --- a/internal/httpapi/library_restore.go +++ b/internal/httpapi/library_restore.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "net/http" + "strconv" "strings" "time" @@ -86,7 +87,10 @@ func libraryRestoreEnqueue(w http.ResponseWriter, r *http.Request, config Config return } now := time.Now().UTC() - jobID := "librestore-" + request.Scope + "-" + now.Format("20060102T150405.000000") + // 作业 id 会作为维护窗口的 job 标识落进 freeze journal,而冻结协调器只接受 + // [A-Za-z0-9_-]:带 "." 的时间戳会让恢复在开窗口时被拒(真跑报 invalid freeze request), + // 所以用纳秒整数,和同步作业保持一致。 + jobID := "librestore-" + request.Scope + "-" + strconv.FormatInt(now.UnixNano(), 10) job, reused, err := store.CreateLibraryJob(r.Context(), governance.LibraryJob{ ID: jobID, Scope: request.Scope, Kind: governance.LibraryJobRestore, IdempotencyKey: "library.restore:" + request.Scope + ":" + request.SnapshotID, @@ -107,7 +111,7 @@ func libraryRestoreEnqueue(w http.ResponseWriter, r *http.Request, config Config if reused && (job.State == governance.LibraryJobFailed || job.State == governance.LibraryJobCancelled) { job, reused, err = store.CreateLibraryJob(r.Context(), governance.LibraryJob{ ID: jobID + "-retry", Scope: request.Scope, Kind: governance.LibraryJobRestore, - IdempotencyKey: "library.restore:" + request.Scope + ":" + request.SnapshotID + ":retry:" + now.Format("20060102T150405.000000000"), + IdempotencyKey: "library.restore:" + request.Scope + ":" + request.SnapshotID + ":retry:" + strconv.FormatInt(now.UnixNano(), 10), RequestDigest: digest, TargetSnapshotID: request.SnapshotID, Actor: identity.Username, }, governance.SyncStages(), now) if err != nil || reused { diff --git a/internal/httpapi/library_restore_test.go b/internal/httpapi/library_restore_test.go index e3355d3..92245a2 100644 --- a/internal/httpapi/library_restore_test.go +++ b/internal/httpapi/library_restore_test.go @@ -85,3 +85,31 @@ func TestLibraryJobResumeRejectsNonFailedJobs(t *testing.T) { t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) } } + +// 恢复作业 id 会作为维护窗口的 job 标识落进 freeze journal,协调器只接受 +// [A-Za-z0-9_-]。带 "." 的时间戳曾经让每次恢复都在开窗口时被拒(invalid freeze request)。 +func TestLibraryRestoreJobIDIsAFreezeSafeIdentifier(t *testing.T) { + store := &fakeLibraryStore{fakeJobStore: fakeJobStore{enqueueEchoRequestedID: true}} + digest, err := libraryRestoreParamsDigest("internal", "snap-1") + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + store.grant = &governance.LibraryReleaseAuthorization{ID: "a", Scope: "internal", SnapshotID: "snap-1", + ParamsDigest: digest, ExpiresAt: now.Add(time.Hour), CreatedAt: now} + config := Config{Store: store, ProductionReleaseRequestsEnabled: true, ProductionBackupRestoreVerified: true} + recorder := httptest.NewRecorder() + libraryRestoreEnqueue(recorder, restoreRequest(`{"scope":"internal","snapshot_id":"snap-1"}`, roleAdmin), config) + if recorder.Code != http.StatusAccepted { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + id := store.lastJob.ID + if id == "" || len(id) > 128 { + t.Fatalf("作业 id 形状不对:%q", id) + } + for _, r := range id { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') { + t.Fatalf("作业 id 含维护窗口不接受的字符 %q:%q", r, id) + } + } +}