Fall back to local artifacts when the blob store is a typed nil
ci / vet-and-test (pull_request) Successful in 46s
ci / vet-and-test (pull_request) Successful in 46s
Why: an unset S3 client passed through the Store interface is not a nil interface, so uploads panic instead of using the local artifact directory. Evidence: TestIsNil and TestFinishOpenTypedNilS3UsesLocalArtifacts failed before this change and pass after it. The PostgreSQL case skips unless AGENT_PG_TEST_DSN is set. Impact: only an unset blob store falls back; a constructed S3 or local store is unchanged. Refs: internal/blobstore/blobstore.go internal/store/store.go Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@@ -19,6 +20,21 @@ type Store interface {
|
||||
Delete(ref string) error
|
||||
}
|
||||
|
||||
// IsNil 判断对象存储是否未配置。nil 接口,以及放进接口后的带类型空指针
|
||||
// (例如未设置端点时的 (*S3)(nil)),都算未配置。已构造的实现返回 false。
|
||||
func IsNil(s Store) bool {
|
||||
if s == nil {
|
||||
return true
|
||||
}
|
||||
v := reflect.ValueOf(s)
|
||||
switch v.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||
return v.IsNil()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Local 是本地目录实现(data/artifacts;多实例时把目录指向共享卷即可)。
|
||||
type Local struct {
|
||||
dir string
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package blobstore
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsNil(t *testing.T) {
|
||||
var unset Store
|
||||
if !IsNil(unset) {
|
||||
t.Fatal("nil interface must count as unset")
|
||||
}
|
||||
var typed *S3
|
||||
var asStore Store = typed
|
||||
if !IsNil(asStore) {
|
||||
t.Fatal("typed nil *S3 must count as unset")
|
||||
}
|
||||
local, err := NewLocal(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if IsNil(local) {
|
||||
t.Fatal("local store must count as configured")
|
||||
}
|
||||
s3, err := NewS3("http://127.0.0.1:9/bucket", "us-east-1", "access", "secret", "art/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if IsNil(s3) {
|
||||
t.Fatal("configured S3 must count as configured")
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,7 @@ func OpenPostgresWithBlobs(dsn, artifactsDir string, blobs blobstore.Store) (*St
|
||||
|
||||
// OpenPostgresWithOptions 按 KTD12 连接预算打开 PG 档;QueryTimeoutSec>0 时
|
||||
// 以 statement_timeout 运行参数附着到 DSN(对 URL 与键值两种形式均生效)。
|
||||
// blobs 为 nil(含带类型的空指针,例如未配置端点时的 (*blobstore.S3)(nil))时回落本地目录。
|
||||
func OpenPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store) (*Store, error) {
|
||||
return openPostgresWithOptions(dsn, artifactsDir, pool, blobs, true)
|
||||
}
|
||||
@@ -263,7 +264,7 @@ func AppendPGRuntimeParam(dsn, key, value string) string {
|
||||
}
|
||||
|
||||
func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts bool) (*Store, error) {
|
||||
if blobs == nil {
|
||||
if blobstore.IsNil(blobs) {
|
||||
var err error
|
||||
if createArtifacts {
|
||||
blobs, err = blobstore.NewLocal(artifactsDir)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/blobstore"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func TestFinishOpenTypedNilS3UsesLocalArtifacts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st := openSQLiteWithBlobs(t, dir, (*blobstore.S3)(nil))
|
||||
assertLocalArtifactRoundTrip(t, st, filepath.Join(dir, "artifacts"))
|
||||
}
|
||||
|
||||
func TestFinishOpenKeepsConfiguredBlobStore(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s3, err := blobstore.NewS3("http://127.0.0.1:9/bucket", "us-east-1", "access", "secret", "art/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := openSQLiteWithBlobs(t, dir, s3)
|
||||
if st.BlobStore() != s3 {
|
||||
t.Fatalf("configured blob store was replaced: %T", st.BlobStore())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenPostgresTypedNilS3UsesLocalArtifacts(t *testing.T) {
|
||||
dsn := os.Getenv("AGENT_PG_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("AGENT_PG_TEST_DSN 未设置:真实 PG 门禁由 CI 的必跑 job 覆盖")
|
||||
}
|
||||
schema := "t_" + strings.ToLower(newID("s")) + "_" + strconv.Itoa(os.Getpid())
|
||||
admin, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ensurePGTrgm(t, admin)
|
||||
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("create schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
|
||||
admin.Close()
|
||||
})
|
||||
arts := filepath.Join(t.TempDir(), "artifacts")
|
||||
st, err := OpenPostgresWithOptions(withTestSearchPath(dsn, schema), arts, PoolOptions{}, (*blobstore.S3)(nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
assertLocalArtifactRoundTrip(t, st, arts)
|
||||
}
|
||||
|
||||
func openSQLiteWithBlobs(t *testing.T, dir string, blobs blobstore.Store) *Store {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
dsn := "file:" + dbPath + "?_pragma=busy_timeout(30000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_txlock=immediate"
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
st, err := finishOpen(db, sqliteDialect{}, filepath.Join(dir, "artifacts"), blobs, PoolOptions{}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func assertLocalArtifactRoundTrip(t *testing.T, st *Store, arts string) {
|
||||
t.Helper()
|
||||
if _, ok := st.BlobStore().(*blobstore.Local); !ok {
|
||||
t.Fatalf("unset blob store must fall back to local, got %T", st.BlobStore())
|
||||
}
|
||||
proj, err := st.CreateProject("k4", "k4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
art := core.Artifact{Owner: "k4", ProjectID: proj.ID, Name: "a.txt", Kind: "data"}
|
||||
if err := st.CreateArtifact(&art, []byte("hello")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.ArtifactContentByID(art.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, []byte("hello")) {
|
||||
t.Fatalf("read back %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(arts, art.ID)); err != nil {
|
||||
t.Fatalf("artifact file missing under local dir: %v", err)
|
||||
}
|
||||
if err := st.UpdateArtifactContent("k4", art.ID, []byte("next")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = st.ArtifactContent("k4", art.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, []byte("next")) {
|
||||
t.Fatalf("overwrite read back %q", got)
|
||||
}
|
||||
if err := st.DeleteArtifact("k4", art.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.ArtifactContentByID(art.ID); err != ErrNotFound {
|
||||
t.Fatalf("deleted artifact content: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(arts, art.ID)); !os.IsNotExist(err) {
|
||||
t.Fatalf("deleted artifact file still present: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user