179 lines
6.1 KiB
Go
179 lines
6.1 KiB
Go
package files
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
|
||
minio "github.com/minio/minio-go/v7"
|
||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||
)
|
||
|
||
// S3FS stores blob bytes in an S3-compatible object store (self-hosted MinIO
|
||
// or AWS S3). It implements BlobBackend using the same content-addressed
|
||
// sha256-hex object key scheme as LocalFS, so file_blobs.object_key stays
|
||
// comparable regardless of which backend wrote a given row and a deployment
|
||
// can run with rows split across both backends (see TELESRV_BLOB_BACKEND).
|
||
//
|
||
// UploadPartBackend is intentionally NOT implemented here: transient upload
|
||
// parts stay on local disk (see cmd/telesrv/main.go) even when the
|
||
// permanent blob backend is s3 -- one S3 round trip per ~512KB chunk isn't
|
||
// worth it for scratch data that's deleted within minutes of assembly.
|
||
type S3FS struct {
|
||
client *minio.Client
|
||
bucket string
|
||
}
|
||
|
||
// NewS3FS creates an S3-compatible blob backend. endpoint is host[:port]
|
||
// without a scheme (e.g. "minio.internal:9000" or "s3.amazonaws.com");
|
||
// useSSL selects http vs https. pathStyle forces path-style addressing
|
||
// (bucket in the URL path rather than as a subdomain), which self-hosted
|
||
// MinIO typically requires and AWS S3 does not.
|
||
func NewS3FS(ctx context.Context, endpoint, accessKeyID, secretAccessKey, bucket, region string, useSSL, pathStyle bool) (*S3FS, error) {
|
||
if endpoint == "" || bucket == "" {
|
||
return nil, fmt.Errorf("s3 blob backend: endpoint and bucket are required")
|
||
}
|
||
lookup := minio.BucketLookupAuto
|
||
if pathStyle {
|
||
lookup = minio.BucketLookupPath
|
||
}
|
||
client, err := minio.New(endpoint, &minio.Options{
|
||
Creds: credentials.NewStaticV4(accessKeyID, secretAccessKey, ""),
|
||
Secure: useSSL,
|
||
Region: region,
|
||
BucketLookup: lookup,
|
||
})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("create s3 client: %w", err)
|
||
}
|
||
exists, err := client.BucketExists(ctx, bucket)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("check s3 bucket %q: %w", bucket, err)
|
||
}
|
||
if !exists {
|
||
if err := client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{Region: region}); err != nil {
|
||
return nil, fmt.Errorf("create s3 bucket %q: %w", bucket, err)
|
||
}
|
||
}
|
||
return &S3FS{client: client, bucket: bucket}, nil
|
||
}
|
||
|
||
// Name 返回后端标识,与 file_blobs.backend 一致。
|
||
func (s *S3FS) Name() string { return "s3" }
|
||
|
||
func (s *S3FS) Put(ctx context.Context, data []byte) (string, error) {
|
||
key, _, _, err := s.PutReader(ctx, bytes.NewReader(data))
|
||
return key, err
|
||
}
|
||
|
||
// PutReader hashes the stream to a local temp file first (so the sha256 key
|
||
// and exact size are known before the S3 PUT, matching LocalFS's
|
||
// content-addressed dedup semantics -- an unknown-length streaming PUT would
|
||
// need a second copy operation to rename-by-hash after the fact, which S3
|
||
// has no equivalent of), then uploads it and checks for an existing object
|
||
// with that key first to skip a redundant PUT.
|
||
func (s *S3FS) PutReader(ctx context.Context, r io.Reader) (string, int64, []byte, error) {
|
||
tmp, err := os.CreateTemp("", "blob-s3-*.tmp")
|
||
if err != nil {
|
||
return "", 0, nil, fmt.Errorf("create s3 blob staging file: %w", err)
|
||
}
|
||
tmpPath := tmp.Name()
|
||
defer os.Remove(tmpPath)
|
||
|
||
h := sha256.New()
|
||
size, err := copyWithContext(ctx, io.MultiWriter(tmp, h), r)
|
||
closeErr := tmp.Close()
|
||
if err != nil {
|
||
return "", 0, nil, fmt.Errorf("stage s3 blob: %w", err)
|
||
}
|
||
if closeErr != nil {
|
||
return "", 0, nil, fmt.Errorf("close s3 blob staging file: %w", closeErr)
|
||
}
|
||
sum := h.Sum(nil)
|
||
key := hex.EncodeToString(sum)
|
||
|
||
if _, err := s.client.StatObject(ctx, s.bucket, key, minio.StatObjectOptions{}); err == nil {
|
||
return key, size, append([]byte(nil), sum...), nil
|
||
} else if !isS3NotFound(err) {
|
||
return "", 0, nil, fmt.Errorf("stat s3 blob: %w", err)
|
||
}
|
||
|
||
f, err := os.Open(tmpPath)
|
||
if err != nil {
|
||
return "", 0, nil, fmt.Errorf("reopen s3 blob staging file: %w", err)
|
||
}
|
||
defer f.Close()
|
||
if _, err := s.client.PutObject(ctx, s.bucket, key, f, size, minio.PutObjectOptions{}); err != nil {
|
||
return "", 0, nil, fmt.Errorf("put s3 blob: %w", err)
|
||
}
|
||
return key, size, append([]byte(nil), sum...), nil
|
||
}
|
||
|
||
func (s *S3FS) Get(ctx context.Context, objectKey string) ([]byte, error) {
|
||
obj, err := s.client.GetObject(ctx, s.bucket, objectKey, minio.GetObjectOptions{})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("get s3 blob: %w", err)
|
||
}
|
||
defer obj.Close()
|
||
data, err := io.ReadAll(obj)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("read s3 blob: %w", err)
|
||
}
|
||
return data, nil
|
||
}
|
||
|
||
// GetRange 语义与 LocalFS.GetRange 一致:只读 [offset, offset+limit) 段,limit<=0 读到末尾,
|
||
// total 取自对象实际大小。
|
||
func (s *S3FS) GetRange(ctx context.Context, objectKey string, offset, limit int64) ([]byte, int64, error) {
|
||
info, err := s.client.StatObject(ctx, s.bucket, objectKey, minio.StatObjectOptions{})
|
||
if err != nil {
|
||
return nil, 0, fmt.Errorf("stat s3 blob: %w", err)
|
||
}
|
||
total := info.Size
|
||
if offset < 0 {
|
||
offset = 0
|
||
}
|
||
if offset >= total {
|
||
return []byte{}, total, nil
|
||
}
|
||
opts := minio.GetObjectOptions{}
|
||
end := total - 1
|
||
if limit > 0 && offset+limit-1 < end {
|
||
end = offset + limit - 1
|
||
}
|
||
if err := opts.SetRange(offset, end); err != nil {
|
||
return nil, 0, fmt.Errorf("set s3 range: %w", err)
|
||
}
|
||
obj, err := s.client.GetObject(ctx, s.bucket, objectKey, opts)
|
||
if err != nil {
|
||
return nil, 0, fmt.Errorf("get s3 blob range: %w", err)
|
||
}
|
||
defer obj.Close()
|
||
data, err := io.ReadAll(obj)
|
||
if err != nil {
|
||
return nil, 0, fmt.Errorf("read s3 blob range: %w", err)
|
||
}
|
||
return data, total, nil
|
||
}
|
||
|
||
// Delete removes the object at objectKey. A missing object is not an error
|
||
// (idempotent, safe to retry). Callers must have already confirmed no other
|
||
// file_blobs row on this backend still references objectKey.
|
||
func (s *S3FS) Delete(ctx context.Context, objectKey string) error {
|
||
if err := s.client.RemoveObject(ctx, s.bucket, objectKey, minio.RemoveObjectOptions{}); err != nil {
|
||
if isS3NotFound(err) {
|
||
return nil
|
||
}
|
||
return fmt.Errorf("delete s3 blob: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func isS3NotFound(err error) bool {
|
||
resp := minio.ToErrorResponse(err)
|
||
return resp.Code == "NoSuchKey" || resp.Code == "NotFound" || resp.StatusCode == 404
|
||
}
|