storage://<id> 是来源实例 storage_backends 的主键;只换桶不改 id 时,目标原版
应用以 storage backend not found 落回默认服务并拒绝整条路径(真实下载 500
code1007)。写入前按租户+目标桶解析目标后端,写入与校验共用同一份行变换;
解析不到就拒绝写入,不再产出目标读不出来的定位符。
试过:目标实例真实同步后 /api/v1/knowledge/{id}/download 读回 500 → 修后复测;
go test ./internal/releaseadapter/v071 与 make verify 15 项全绿。
This commit is contained in:
@@ -26,6 +26,11 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **同步过去的资源行指向来源实例的存储后端 id**:`storage://<id>/minio://<bucket>/<key>`
|
||||
里的 `<id>` 是来源 `storage_backends` 的主键,目标实例没有这条行。只换桶不改 id 时,
|
||||
原版应用落回默认存储服务并拒绝整条路径(真实下载 500 `code 1007
|
||||
invalid MinIO file path`)。现在写入前按"租户 + 目标桶名"在目标库解析后端 id,
|
||||
写入与校验共用同一份行变换;解析不到就拒绝写入,不产出目标读不出来的定位符。
|
||||
- 指纹对空白敏感会让"同一份内容"两边算不出同一个值(PostgreSQL `to_jsonb::text`
|
||||
带空格、Go `json.Marshal` 不带);指纹改为先 `json.Compact` 再摘要。
|
||||
|
||||
|
||||
@@ -50,9 +50,12 @@ type ProductionLibraryConfig struct {
|
||||
// ObjectsBucket 是目标对象存储的桶名:资源行里的定位符要换成它,
|
||||
// 否则同步过去的图/附件仍指向来源桶。
|
||||
ObjectsBucket string
|
||||
MaxDocuments int
|
||||
HTTPClient *http.Client
|
||||
Now func() time.Time
|
||||
// ObjectsBackendID 是目标实例 storage_backends 的主键。留空则按租户+桶名在
|
||||
// 目标库里解析;定位符里的来源后端 id 在目标实例不存在,原版应用会 500。
|
||||
ObjectsBackendID string
|
||||
MaxDocuments int
|
||||
HTTPClient *http.Client
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (config ProductionLibraryConfig) validate(scope ProductionScope) error {
|
||||
@@ -81,6 +84,36 @@ type ProductionLibraryTarget struct {
|
||||
scope ProductionScope
|
||||
search *SearchExporter
|
||||
now func() time.Time
|
||||
// storageBackend 是本次写入使用的目标存储后端 id(写入与校验共用)。
|
||||
storageBackend string
|
||||
}
|
||||
|
||||
// resolveStorageBackend 在目标库里找本次写入的存储后端。找不到就报错:宁可不写,
|
||||
// 也不写一条目标应用读不出来的定位符(上轮就是这样同步"成功"了却下载 500)。
|
||||
func (t *ProductionLibraryTarget) resolveStorageBackend(ctx context.Context) error {
|
||||
if t.config.ObjectsBucket == "" {
|
||||
return nil
|
||||
}
|
||||
if t.config.ObjectsBackendID != "" {
|
||||
if !validSearchIdentifier(t.config.ObjectsBackendID) {
|
||||
return errors.New("目标存储后端 id 形状不合法")
|
||||
}
|
||||
t.storageBackend = t.config.ObjectsBackendID
|
||||
return nil
|
||||
}
|
||||
var id string
|
||||
err := t.pool.QueryRow(ctx, `
|
||||
SELECT id FROM storage_backends
|
||||
WHERE tenant_id=$1 AND coalesce(config->>'bucket_name','')=$2
|
||||
ORDER BY created_at LIMIT 1`, t.scope.TenantID, t.config.ObjectsBucket).Scan(&id)
|
||||
if err != nil {
|
||||
return errors.New("目标实例没有与目标桶匹配的存储后端,拒绝写入不可解析的资源定位符")
|
||||
}
|
||||
if !validSearchIdentifier(id) {
|
||||
return errors.New("目标存储后端 id 形状不合法")
|
||||
}
|
||||
t.storageBackend = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewProductionLibraryTarget(ctx context.Context, config ProductionLibraryConfig, scope ProductionScope) (*ProductionLibraryTarget, error) {
|
||||
@@ -223,6 +256,9 @@ func digestIDs(ids []string) string {
|
||||
|
||||
// Apply 把一类数据从归档写进生产;database 与 wiki 走同一个知识域事务。
|
||||
func (t *ProductionLibraryTarget) Apply(ctx context.Context, stage libraryrelease.Stage, snapshotID string) error {
|
||||
if err := t.resolveStorageBackend(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
archive, err := librarysnapshot.Open(t.config.ArchiveRoot, 256*1024*1024)
|
||||
if err != nil {
|
||||
return errors.New("归档不可用")
|
||||
@@ -394,7 +430,7 @@ func (t *ProductionLibraryTarget) applyResourceBucket(row map[string]json.RawMes
|
||||
if err := json.Unmarshal(raw, &locator); err != nil {
|
||||
return errors.New("资源定位符不可解析")
|
||||
}
|
||||
rewritten, ok := rewriteLocatorBucket(locator, t.config.ObjectsBucket)
|
||||
rewritten, ok := rewriteLocatorTarget(locator, t.storageBackend, t.config.ObjectsBucket)
|
||||
if !ok {
|
||||
return fmt.Errorf("资源定位符无法改写到目标桶:%s", locator)
|
||||
}
|
||||
@@ -537,6 +573,9 @@ func (t *ProductionLibraryTarget) importIndex(ctx context.Context, archive *libr
|
||||
|
||||
// Verify 用行数 + 身份集合摘要 + 索引条数核对生产与预发布快照是否一致。
|
||||
func (t *ProductionLibraryTarget) Verify(ctx context.Context, expect libraryrelease.Expectation) error {
|
||||
if err := t.resolveStorageBackend(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
archive, err := librarysnapshot.Open(t.config.ArchiveRoot, 256*1024*1024)
|
||||
if err != nil {
|
||||
return errors.New("归档不可用")
|
||||
|
||||
@@ -108,6 +108,15 @@ func (importer *ObjectImporter) Promote(ctx context.Context, stage ObjectStage)
|
||||
// 目标实例之后按这一行去取图/附件时会指向测试环境的桶(跨环境引用),
|
||||
// 或者干脆取不到。键不变,只换桶。
|
||||
func rewriteLocatorBucket(locator, bucket string) (string, bool) {
|
||||
return rewriteLocatorTarget(locator, "", bucket)
|
||||
}
|
||||
|
||||
// rewriteLocatorTarget 同时改写定位符里的存储后端 id 与桶。
|
||||
//
|
||||
// 为什么后端 id 也要改:storage://<id>/minio://<bucket>/<key> 里的 id 是来源实例
|
||||
// storage_backends 的主键。目标实例有自己的行;只改桶不改 id,原版应用会以
|
||||
// "storage backend not found" 落到默认服务,然后拒绝整条路径(真跑 500 code1007)。
|
||||
func rewriteLocatorTarget(locator, backendID, bucket string) (string, bool) {
|
||||
if bucket == "" || locator == "" || locator != strings.TrimSpace(locator) {
|
||||
return "", false
|
||||
}
|
||||
@@ -122,6 +131,12 @@ func rewriteLocatorBucket(locator, bucket string) (string, bool) {
|
||||
if parts[1] == "" {
|
||||
return "", false
|
||||
}
|
||||
if backendID != "" {
|
||||
if !validSearchIdentifier(backendID) {
|
||||
return "", false
|
||||
}
|
||||
parts[0] = backendID
|
||||
}
|
||||
prefix, rest = "storage://"+parts[0]+"/", parts[1]
|
||||
}
|
||||
separator := strings.Index(rest, "://")
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package v071
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 同步把字节写进目标桶,但资源行是从快照原样搬的:定位符里写着来源桶时,
|
||||
// 目标实例之后取图/附件会指向测试环境的桶(跨环境引用)或直接取不到。
|
||||
@@ -34,3 +37,39 @@ func TestRewriteLocatorBucketKeepsKeyAndSwapsBucket(t *testing.T) {
|
||||
t.Fatalf("裸键被改写了:%q ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// 只换桶不够:storage://<id> 是来源实例 storage_backends 的主键。目标实例没有这条行时,
|
||||
// 原版应用会以 storage backend not found 落到默认服务并拒绝整条路径(真跑 500 code1007),
|
||||
// 所以后端 id 必须一起换成本次写入解析到的目标 id。
|
||||
func TestRewriteLocatorSwapsStorageBackendWithBucket(t *testing.T) {
|
||||
got, ok := rewriteLocatorTarget("storage://source-backend/minio://kgmaint-1028a/10001/k/ab.md",
|
||||
"target-backend", "kgseal-1029a")
|
||||
if !ok || got != "storage://target-backend/minio://kgseal-1029a/10001/k/ab.md" {
|
||||
t.Fatalf("rewrite=%q ok=%v", got, ok)
|
||||
}
|
||||
// 没有解析到后端时不猜:保留原 id(调用方在没有目标桶时不会走到这里)。
|
||||
got, ok = rewriteLocatorTarget("storage://source-backend/minio://b/k", "", "b2")
|
||||
if !ok || got != "storage://source-backend/minio://b2/k" {
|
||||
t.Fatalf("rewrite without backend=%q ok=%v", got, ok)
|
||||
}
|
||||
if _, ok := rewriteLocatorTarget("storage://x/minio://b/k", "../evil", "b2"); ok {
|
||||
t.Fatal("非法后端 id 被接受了")
|
||||
}
|
||||
}
|
||||
|
||||
// 行变换用解析到的目标后端与目标桶:写入与校验共用这一份,摘要才不会两边打架。
|
||||
func TestResourceRowTransformRewritesBackendAndBucket(t *testing.T) {
|
||||
target := &ProductionLibraryTarget{config: ProductionLibraryConfig{ObjectsBucket: "kgseal-1029a"},
|
||||
storageBackend: "target-backend"}
|
||||
row := map[string]json.RawMessage{"physical_path": json.RawMessage(`"storage://source-backend/minio://kgmaint-1028a/10001/k/ab.md"`)}
|
||||
if err := target.rowTransform("resources")(row); err != nil {
|
||||
t.Fatalf("row transform failed: %v", err)
|
||||
}
|
||||
var got string
|
||||
if err := json.Unmarshal(row["physical_path"], &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "storage://target-backend/minio://kgseal-1029a/10001/k/ab.md" {
|
||||
t.Fatalf("transform=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user