s3 support

This commit is contained in:
onysd 2026-08-04 23:09:11 +03:00
parent fa5cfaf14d
commit 03f10b66ee
53 changed files with 2796 additions and 102 deletions

View file

@ -0,0 +1,179 @@
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
}