mirror of
https://github.com/hanzoai/vfs.git
synced 2026-10-30 18:18:21 +00:00
vfs was the last service reaching S3 through github.com/aws/aws-sdk-go-v2 while
the rest of the fleet (vm, commerce, licensing, ai) already used the Hanzo
Storage Go SDK. Both speak S3, so either works against our own storage — but a
second client meant a second credential model, a second retry policy, a second
error taxonomy, and ~30 extra modules in the graph for a backend that makes
five calls.
The SDK also moved orgs on the way in: github.com/hanzoai/s3-go is now
github.com/hanzos3/go. hanzoai/* is for top-level products; a product's own SDK
belongs in that product's org, which zap-proto/{go,zip,http} and hanzo-ds/go
already do.
Every aws-sdk-go-v2 module is gone from go.mod. The endpoint still comes from
the URL's `endpoint` parameter or AWS_ENDPOINT_URL, defaulting to Hanzo Storage.
One behavioural note: this SDK's GetObject is lazy, so a missing key surfaces on
the first read rather than on the call — backend.ErrNotFound is mapped in both
places so callers still see one sentinel.
Co-authored-by: Hanzo Dev <[email protected]>
179 lines
4.6 KiB
Go
179 lines
4.6 KiB
Go
// Package s3 is an S3 Backend on Hanzo Storage.
|
|
//
|
|
// It speaks to github.com/hanzoai/s3 through the Hanzo Storage Go SDK
|
|
// (github.com/hanzos3/go) rather than the AWS SDK. Both talk S3, so either
|
|
// would work against our own storage — but the fleet already standardises on
|
|
// that SDK (vm, commerce, licensing, ai), and carrying a second S3 client meant
|
|
// a second credential model, a second retry policy, a second error taxonomy,
|
|
// and ~30 extra modules in the graph for a backend that makes five calls.
|
|
//
|
|
// Endpoint override (Hanzo Storage, R2, any S3-compatible service) via the
|
|
// URL's `endpoint` query parameter; credentials come from the environment.
|
|
package s3
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
|
|
hs3 "github.com/hanzos3/go"
|
|
"github.com/hanzos3/go/pkg/credentials"
|
|
|
|
"github.com/hanzoai/vfs/pkg/backend"
|
|
)
|
|
|
|
func init() {
|
|
backend.Register("s3", openS3)
|
|
}
|
|
|
|
type s3Backend struct {
|
|
client *hs3.Client
|
|
bucket string
|
|
prefix string
|
|
desc string
|
|
|
|
closeOnce sync.Once
|
|
}
|
|
|
|
func openS3(_ context.Context, u *url.URL) (backend.Backend, error) {
|
|
bucket := u.Host
|
|
if bucket == "" {
|
|
return nil, fmt.Errorf("s3 backend: missing bucket in %q", u.String())
|
|
}
|
|
prefix := strings.TrimPrefix(u.Path, "/")
|
|
|
|
// The endpoint decides which storage this is; a bare URL means Hanzo
|
|
// Storage via the ambient env, which is what a service in-cluster gets.
|
|
endpoint := u.Query().Get("endpoint")
|
|
if endpoint == "" {
|
|
endpoint = os.Getenv("AWS_ENDPOINT_URL")
|
|
}
|
|
if endpoint == "" {
|
|
endpoint = "s3.hanzo.ai"
|
|
}
|
|
secure := true
|
|
if e, err := url.Parse(endpoint); err == nil && e.Host != "" {
|
|
secure = e.Scheme != "http"
|
|
endpoint = e.Host
|
|
}
|
|
|
|
cli, err := hs3.New(endpoint, &hs3.Options{
|
|
// EnvAWS then EnvMinio then the IAM role, in that order — the same
|
|
// chain every other Hanzo service resolves credentials through.
|
|
Creds: credentials.NewChainCredentials(defaultCredProviders()),
|
|
Secure: secure,
|
|
Region: u.Query().Get("region"),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("s3 backend: %w", err)
|
|
}
|
|
|
|
return &s3Backend{
|
|
client: cli,
|
|
bucket: bucket,
|
|
prefix: prefix,
|
|
desc: u.String(),
|
|
}, nil
|
|
}
|
|
|
|
func defaultCredProviders() []credentials.Provider {
|
|
return []credentials.Provider{
|
|
&credentials.EnvAWS{},
|
|
&credentials.EnvMinio{},
|
|
&credentials.IAM{},
|
|
}
|
|
}
|
|
|
|
func (s *s3Backend) k(key string) string {
|
|
if s.prefix == "" {
|
|
return key
|
|
}
|
|
return s.prefix + "/" + key
|
|
}
|
|
|
|
// notFound maps the SDK's error taxonomy onto backend.ErrNotFound so callers
|
|
// see one sentinel regardless of which storage answered.
|
|
func notFound(err error, key string) error {
|
|
switch hs3.ToErrorResponse(err).Code {
|
|
case "NoSuchKey", "NoSuchBucket", "NotFound":
|
|
return fmt.Errorf("%w: %s", backend.ErrNotFound, key)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *s3Backend) Get(ctx context.Context, key string) ([]byte, error) {
|
|
obj, err := s.client.GetObject(ctx, s.bucket, s.k(key), hs3.GetObjectOptions{})
|
|
if err != nil {
|
|
return nil, notFound(err, key)
|
|
}
|
|
defer obj.Close()
|
|
b, err := io.ReadAll(obj)
|
|
if err != nil {
|
|
// GetObject is lazy: a missing key surfaces on the first read, not on
|
|
// the call, so the sentinel mapping has to happen here too.
|
|
return nil, notFound(err, key)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func (s *s3Backend) Put(ctx context.Context, key string, data []byte) error {
|
|
_, err := s.client.PutObject(ctx, s.bucket, s.k(key),
|
|
bytes.NewReader(data), int64(len(data)), hs3.PutObjectOptions{})
|
|
return err
|
|
}
|
|
|
|
func (s *s3Backend) Delete(ctx context.Context, key string) error {
|
|
// Idempotent by contract — an absent key is not an error.
|
|
return s.client.RemoveObject(ctx, s.bucket, s.k(key), hs3.RemoveObjectOptions{})
|
|
}
|
|
|
|
func (s *s3Backend) List(ctx context.Context, prefix string) (<-chan string, <-chan error) {
|
|
keys := make(chan string, 256)
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
defer close(keys)
|
|
defer close(errCh)
|
|
for obj := range s.client.ListObjects(ctx, s.bucket, hs3.ListObjectsOptions{
|
|
Prefix: s.k(prefix),
|
|
Recursive: true,
|
|
}) {
|
|
if obj.Err != nil {
|
|
errCh <- obj.Err
|
|
return
|
|
}
|
|
k := obj.Key
|
|
if s.prefix != "" {
|
|
k = strings.TrimPrefix(k, s.prefix+"/")
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case keys <- k:
|
|
}
|
|
}
|
|
}()
|
|
return keys, errCh
|
|
}
|
|
|
|
func (s *s3Backend) Stat(ctx context.Context, key string) (int64, error) {
|
|
info, err := s.client.StatObject(ctx, s.bucket, s.k(key), hs3.StatObjectOptions{})
|
|
if err != nil {
|
|
return 0, notFound(err, key)
|
|
}
|
|
return info.Size, nil
|
|
}
|
|
|
|
func (s *s3Backend) Close() error {
|
|
s.closeOnce.Do(func() {})
|
|
return nil
|
|
}
|
|
|
|
func (s *s3Backend) String() string { return s.desc }
|
|
|
|
var _ backend.Backend = (*s3Backend)(nil)
|