diff --git a/internal/blobstore/blobstore.go b/internal/blobstore/blobstore.go index ab981f1..570d437 100644 --- a/internal/blobstore/blobstore.go +++ b/internal/blobstore/blobstore.go @@ -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 diff --git a/internal/blobstore/nil_test.go b/internal/blobstore/nil_test.go new file mode 100644 index 0000000..4bb375a --- /dev/null +++ b/internal/blobstore/nil_test.go @@ -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") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index edfff68..ae22d78 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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) diff --git a/internal/store/typed_nil_blob_test.go b/internal/store/typed_nil_blob_test.go new file mode 100644 index 0000000..e17c6b2 --- /dev/null +++ b/internal/store/typed_nil_blob_test.go @@ -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) + } +}