Add S3-compatible storage backend
This commit is contained in:
430
internal/adapters/s3/backend.go
Normal file
430
internal/adapters/s3/backend.go
Normal file
@@ -0,0 +1,430 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awscfg "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/aws/smithy-go"
|
||||
)
|
||||
|
||||
type Client interface {
|
||||
HeadObject(ctx context.Context, input *awss3.HeadObjectInput, optFns ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error)
|
||||
GetObject(ctx context.Context, input *awss3.GetObjectInput, optFns ...func(*awss3.Options)) (*awss3.GetObjectOutput, error)
|
||||
PutObject(ctx context.Context, input *awss3.PutObjectInput, optFns ...func(*awss3.Options)) (*awss3.PutObjectOutput, error)
|
||||
ListObjectsV2(ctx context.Context, input *awss3.ListObjectsV2Input, optFns ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error)
|
||||
DeleteObject(ctx context.Context, input *awss3.DeleteObjectInput, optFns ...func(*awss3.Options)) (*awss3.DeleteObjectOutput, error)
|
||||
}
|
||||
|
||||
type Backend struct {
|
||||
client Client
|
||||
bucket string
|
||||
prefix string
|
||||
}
|
||||
|
||||
func New(ctx context.Context, options Options) (*Backend, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options, err := options.normalized()
|
||||
if err != nil {
|
||||
return nil, storage.NewError(storage.OpOpenBackend, BackendName, options.Prefix, storage.ErrInvalidPath, err)
|
||||
}
|
||||
loadOptions := []func(*awscfg.LoadOptions) error{
|
||||
awscfg.WithRegion(options.Region),
|
||||
}
|
||||
if options.AccessKeyID != "" {
|
||||
loadOptions = append(loadOptions, awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(options.AccessKeyID, options.SecretAccessKey, "")))
|
||||
}
|
||||
cfg, err := awscfg.LoadDefaultConfig(ctx, loadOptions...)
|
||||
if err != nil {
|
||||
return nil, storage.NewError(storage.OpOpenBackend, BackendName, options.Endpoint, storage.ErrUnknown, err)
|
||||
}
|
||||
client := awss3.NewFromConfig(cfg, func(o *awss3.Options) {
|
||||
o.BaseEndpoint = aws.String(options.Endpoint)
|
||||
o.UsePathStyle = options.ForcePathStyle
|
||||
})
|
||||
return NewWithClient(client, options)
|
||||
}
|
||||
|
||||
func NewWithClient(client Client, options Options) (*Backend, error) {
|
||||
options, err := options.normalized()
|
||||
if err != nil {
|
||||
return nil, storage.NewError(storage.OpOpenBackend, BackendName, options.Prefix, storage.ErrInvalidPath, err)
|
||||
}
|
||||
if client == nil {
|
||||
return nil, storage.NewError(storage.OpOpenBackend, BackendName, options.Bucket, storage.ErrInvalidPath, fmt.Errorf("client is required"))
|
||||
}
|
||||
return &Backend{
|
||||
client: client,
|
||||
bucket: options.Bucket,
|
||||
prefix: options.Prefix,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *Backend) ReadFile(ctx context.Context, logicalPath string) ([]byte, error) {
|
||||
reader, err := b.OpenReader(ctx, logicalPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, storage.NewError(storage.OpReadFile, BackendName, logicalPath, storage.ErrUnknown, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (b *Backend) OpenReader(ctx context.Context, logicalPath string) (io.ReadCloser, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := b.objectKey(logicalPath, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output, err := b.client.GetObject(ctx, &awss3.GetObjectInput{
|
||||
Bucket: aws.String(b.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, b.translateError(storage.OpOpenReader, logicalPath, err)
|
||||
}
|
||||
return output.Body, nil
|
||||
}
|
||||
|
||||
func (b *Backend) WriteFile(ctx context.Context, logicalPath string, data []byte, opts storage.WriteOptions) (storage.Entry, error) {
|
||||
opts.Size = int64(len(data))
|
||||
opts.SizeKnown = true
|
||||
return b.WriteFrom(ctx, logicalPath, bytes.NewReader(data), opts)
|
||||
}
|
||||
|
||||
func (b *Backend) WriteFrom(ctx context.Context, logicalPath string, r io.Reader, opts storage.WriteOptions) (storage.Entry, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return storage.Entry{}, err
|
||||
}
|
||||
key, err := b.objectKey(logicalPath, false)
|
||||
if err != nil {
|
||||
return storage.Entry{}, err
|
||||
}
|
||||
if !opts.Overwrite {
|
||||
_, err := b.client.HeadObject(ctx, &awss3.HeadObjectInput{
|
||||
Bucket: aws.String(b.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err == nil {
|
||||
return storage.Entry{}, storage.NewError(storage.OpWriteFrom, BackendName, logicalPath, storage.ErrAlreadyExist, nil)
|
||||
}
|
||||
if !isNotFound(err) {
|
||||
return storage.Entry{}, b.translateError(storage.OpWriteFrom, logicalPath, err)
|
||||
}
|
||||
}
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return storage.Entry{}, storage.NewError(storage.OpWriteFrom, BackendName, logicalPath, storage.ErrUnknown, err)
|
||||
}
|
||||
if opts.SizeKnown && int64(len(data)) != opts.Size {
|
||||
return storage.Entry{}, storage.NewError(storage.OpWriteFrom, BackendName, logicalPath, storage.ErrConflict, fmt.Errorf("stream size %d does not match expected size %d", len(data), opts.Size))
|
||||
}
|
||||
contentType := opts.ContentType
|
||||
if contentType == "" {
|
||||
contentType = ContentType(logicalPath)
|
||||
}
|
||||
_, err = b.client.PutObject(ctx, &awss3.PutObjectInput{
|
||||
Bucket: aws.String(b.bucket),
|
||||
Key: aws.String(key),
|
||||
Body: bytes.NewReader(data),
|
||||
ContentLength: aws.Int64(int64(len(data))),
|
||||
ContentType: aws.String(contentType),
|
||||
})
|
||||
if err != nil {
|
||||
return storage.Entry{}, b.translateError(storage.OpWriteFrom, logicalPath, err)
|
||||
}
|
||||
return storage.Entry{Path: logicalPath, Type: storage.EntryTypeFile, Size: int64(len(data))}, nil
|
||||
}
|
||||
|
||||
func (b *Backend) Stat(ctx context.Context, logicalPath string) (storage.Entry, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return storage.Entry{}, err
|
||||
}
|
||||
key, err := b.objectKey(logicalPath, false)
|
||||
if err != nil {
|
||||
return storage.Entry{}, err
|
||||
}
|
||||
output, err := b.client.HeadObject(ctx, &awss3.HeadObjectInput{
|
||||
Bucket: aws.String(b.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return storage.Entry{}, b.translateError(storage.OpStat, logicalPath, err)
|
||||
}
|
||||
size := int64(0)
|
||||
if output.ContentLength != nil {
|
||||
size = *output.ContentLength
|
||||
}
|
||||
return storage.Entry{Path: logicalPath, Type: storage.EntryTypeFile, Size: size}, nil
|
||||
}
|
||||
|
||||
func (b *Backend) Walk(ctx context.Context, logicalPrefix string, opts storage.WalkOptions, fn storage.WalkFunc) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := storage.ValidatePrefix(logicalPrefix); err != nil {
|
||||
return err
|
||||
}
|
||||
visited := 0
|
||||
emit := func(entry storage.Entry) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Limit > 0 && visited >= opts.Limit {
|
||||
return storage.ErrStopWalk
|
||||
}
|
||||
visited++
|
||||
if err := fn(entry); err != nil {
|
||||
if errors.Is(err, storage.ErrStopWalk) {
|
||||
return storage.ErrStopWalk
|
||||
}
|
||||
return storage.NewError(storage.OpWalk, BackendName, entry.Path, storage.ErrUnknown, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if logicalPrefix != "" {
|
||||
entry, err := b.Stat(ctx, logicalPrefix)
|
||||
if err == nil {
|
||||
if err := emit(entry); errors.Is(err, storage.ErrStopWalk) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !storage.IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err := b.walkObjects(ctx, logicalPrefix, opts, emit)
|
||||
if errors.Is(err, storage.ErrStopWalk) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *Backend) HasAny(ctx context.Context, logicalPrefix string) (bool, error) {
|
||||
found := false
|
||||
err := b.Walk(ctx, logicalPrefix, storage.WalkOptions{Recursive: false, Limit: 1}, func(storage.Entry) error {
|
||||
found = true
|
||||
return storage.ErrStopWalk
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (b *Backend) DeleteManagedBundle(ctx context.Context, bundlePath string, managedOutputPaths []string, opts storage.DeleteOptions) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
targets, err := storage.ManagedBundleTargets(bundlePath, managedOutputPaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, target := range targets {
|
||||
key, err := b.objectKey(target, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if key == b.prefix {
|
||||
return storage.NewError(storage.OpDeleteManagedBundle, BackendName, target, storage.ErrInvalidPath, nil)
|
||||
}
|
||||
if !opts.IgnoreMissing {
|
||||
_, err := b.client.HeadObject(ctx, &awss3.HeadObjectInput{
|
||||
Bucket: aws.String(b.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return b.translateError(storage.OpDeleteManagedBundle, target, err)
|
||||
}
|
||||
}
|
||||
_, err = b.client.DeleteObject(ctx, &awss3.DeleteObjectInput{
|
||||
Bucket: aws.String(b.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
if opts.IgnoreMissing && isNotFound(err) {
|
||||
continue
|
||||
}
|
||||
return b.translateError(storage.OpDeleteManagedBundle, target, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) walkObjects(ctx context.Context, logicalPrefix string, opts storage.WalkOptions, emit func(storage.Entry) error) error {
|
||||
listPrefix, err := b.listPrefix(logicalPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delimiter := ""
|
||||
if !opts.Recursive {
|
||||
delimiter = "/"
|
||||
}
|
||||
var token *string
|
||||
for {
|
||||
output, err := b.client.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{
|
||||
Bucket: aws.String(b.bucket),
|
||||
Prefix: aws.String(listPrefix),
|
||||
Delimiter: aws.String(delimiter),
|
||||
ContinuationToken: token,
|
||||
})
|
||||
if err != nil {
|
||||
return b.translateError(storage.OpWalk, logicalPrefix, err)
|
||||
}
|
||||
entries := entriesFromList(logicalPrefix, b.prefix, output)
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Path < entries[j].Path })
|
||||
for _, entry := range entries {
|
||||
if entry.Path == "" {
|
||||
continue
|
||||
}
|
||||
if err := emit(entry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if output.IsTruncated == nil || !*output.IsTruncated {
|
||||
return nil
|
||||
}
|
||||
token = output.NextContinuationToken
|
||||
}
|
||||
}
|
||||
|
||||
func entriesFromList(logicalPrefix, rootPrefix string, output *awss3.ListObjectsV2Output) []storage.Entry {
|
||||
seen := make(map[string]storage.Entry)
|
||||
for _, object := range output.Contents {
|
||||
if object.Key == nil {
|
||||
continue
|
||||
}
|
||||
logicalPath := logicalPathFromKey(rootPrefix, *object.Key)
|
||||
if logicalPath == "" || logicalPath == logicalPrefix {
|
||||
continue
|
||||
}
|
||||
size := int64(0)
|
||||
if object.Size != nil {
|
||||
size = *object.Size
|
||||
}
|
||||
seen[logicalPath] = storage.Entry{Path: logicalPath, Type: storage.EntryTypeFile, Size: size}
|
||||
}
|
||||
for _, commonPrefix := range output.CommonPrefixes {
|
||||
if commonPrefix.Prefix == nil {
|
||||
continue
|
||||
}
|
||||
logicalPath := strings.TrimSuffix(logicalPathFromKey(rootPrefix, *commonPrefix.Prefix), "/")
|
||||
if logicalPath == "" || logicalPath == logicalPrefix {
|
||||
continue
|
||||
}
|
||||
seen[logicalPath] = storage.Entry{Path: logicalPath, Type: storage.EntryTypeDirectory}
|
||||
}
|
||||
entries := make([]storage.Entry, 0, len(seen))
|
||||
for _, entry := range seen {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func (b *Backend) objectKey(logicalPath string, allowEmpty bool) (string, error) {
|
||||
if logicalPath == "" {
|
||||
if !allowEmpty {
|
||||
return "", storage.NewError(storage.OpValidatePath, BackendName, logicalPath, storage.ErrInvalidPath, nil)
|
||||
}
|
||||
return b.prefix, nil
|
||||
}
|
||||
if err := storage.ValidatePath(logicalPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if b.prefix == "" {
|
||||
return logicalPath, nil
|
||||
}
|
||||
return b.prefix + "/" + logicalPath, nil
|
||||
}
|
||||
|
||||
func (b *Backend) listPrefix(logicalPrefix string) (string, error) {
|
||||
key, err := b.objectKey(logicalPrefix, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if key != "" {
|
||||
key = strings.TrimSuffix(key, "/") + "/"
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func logicalPathFromKey(rootPrefix, key string) string {
|
||||
if rootPrefix == "" {
|
||||
return key
|
||||
}
|
||||
if key == rootPrefix {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(key, rootPrefix+"/")
|
||||
}
|
||||
|
||||
func ContentType(logicalPath string) string {
|
||||
switch strings.ToLower(path.Ext(logicalPath)) {
|
||||
case ".md":
|
||||
return "text/markdown; charset=utf-8"
|
||||
case ".html":
|
||||
return "text/html; charset=utf-8"
|
||||
case ".json":
|
||||
return "application/json"
|
||||
case ".txt":
|
||||
return "text/plain; charset=utf-8"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
func isNotFound(err error) bool {
|
||||
var notFound *types.NotFound
|
||||
if errors.As(err, ¬Found) {
|
||||
return true
|
||||
}
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
switch apiErr.ErrorCode() {
|
||||
case "NotFound", "NoSuchKey", "404":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *Backend) translateError(op, logicalPath string, err error) error {
|
||||
kind := storage.ErrUnknown
|
||||
if isNotFound(err) {
|
||||
kind = storage.ErrNotFound
|
||||
} else {
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
switch apiErr.ErrorCode() {
|
||||
case "AccessDenied", "InvalidAccessKeyId", "SignatureDoesNotMatch":
|
||||
kind = storage.ErrPermission
|
||||
case "SlowDown", "RequestTimeout", "ServiceUnavailable", "InternalError":
|
||||
kind = storage.ErrTemporary
|
||||
case "InvalidBucketName", "NoSuchBucket":
|
||||
kind = storage.ErrInvalidPath
|
||||
}
|
||||
}
|
||||
}
|
||||
return storage.NewError(op, BackendName, logicalPath, kind, err)
|
||||
}
|
||||
357
internal/adapters/s3/backend_test.go
Normal file
357
internal/adapters/s3/backend_test.go
Normal file
@@ -0,0 +1,357 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
)
|
||||
|
||||
func TestKeyAndPrefixNormalization(t *testing.T) {
|
||||
backend := newTestBackend(t, "root/prefix", nil)
|
||||
key, err := backend.objectKey("bundle/report.md", false)
|
||||
if err != nil {
|
||||
t.Fatalf("objectKey() error = %v", err)
|
||||
}
|
||||
if got, want := key, "root/prefix/bundle/report.md"; got != want {
|
||||
t.Fatalf("objectKey() = %q, want %q", got, want)
|
||||
}
|
||||
listPrefix, err := backend.listPrefix("bundle")
|
||||
if err != nil {
|
||||
t.Fatalf("listPrefix() error = %v", err)
|
||||
}
|
||||
if got, want := listPrefix, "root/prefix/bundle/"; got != want {
|
||||
t.Fatalf("listPrefix() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTraversalRejected(t *testing.T) {
|
||||
backend := newTestBackend(t, "", nil)
|
||||
for _, logicalPath := range []string{"/absolute", "../escape", "a/../b", `a\b`} {
|
||||
t.Run(logicalPath, func(t *testing.T) {
|
||||
if _, err := backend.objectKey(logicalPath, false); err == nil || !storage.IsInvalidPath(err) {
|
||||
t.Fatalf("objectKey() error = %v, want invalid path", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentType(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"report.md": "text/markdown; charset=utf-8",
|
||||
"report.html": "text/html; charset=utf-8",
|
||||
"state.json": "application/json",
|
||||
"summary.txt": "text/plain; charset=utf-8",
|
||||
"data.bin": "application/octet-stream",
|
||||
}
|
||||
for path, want := range tests {
|
||||
if got := ContentType(path); got != want {
|
||||
t.Fatalf("ContentType(%q) = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatRequiresExactObject(t *testing.T) {
|
||||
client := newFakeClient(map[string]string{"root/dir/file.txt": "data"})
|
||||
backend := newTestBackend(t, "root", client)
|
||||
_, err := backend.Stat(context.Background(), "dir")
|
||||
if err == nil || !storage.IsNotFound(err) {
|
||||
t.Fatalf("Stat() error = %v, want not found", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFromChecksOverwriteBeforePut(t *testing.T) {
|
||||
client := newFakeClient(map[string]string{"root/report.md": "old"})
|
||||
backend := newTestBackend(t, "root", client)
|
||||
_, err := backend.WriteFile(context.Background(), "report.md", []byte("new"), storage.WriteOptions{})
|
||||
if err == nil || !storage.IsAlreadyExists(err) {
|
||||
t.Fatalf("WriteFile() error = %v, want already exists", err)
|
||||
}
|
||||
if len(client.putKeys) != 0 {
|
||||
t.Fatalf("put keys = %v, want none", client.putKeys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFromPutsNewObjectWithContentType(t *testing.T) {
|
||||
client := newFakeClient(nil)
|
||||
backend := newTestBackend(t, "root", client)
|
||||
entry, err := backend.WriteFile(context.Background(), "report.html", []byte("<p>ok</p>"), storage.WriteOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
if entry.Path != "report.html" || entry.Size != 9 || entry.Type != storage.EntryTypeFile {
|
||||
t.Fatalf("entry = %#v", entry)
|
||||
}
|
||||
if got, want := client.objects["root/report.html"], "<p>ok</p>"; got != want {
|
||||
t.Fatalf("object = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := client.contentTypes["root/report.html"], "text/html; charset=utf-8"; got != want {
|
||||
t.Fatalf("content type = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkUsesPagination(t *testing.T) {
|
||||
client := newFakeClient(nil)
|
||||
client.listPages = []awss3.ListObjectsV2Output{
|
||||
{
|
||||
Contents: []types.Object{{Key: aws.String("root/a.txt"), Size: aws.Int64(1)}},
|
||||
IsTruncated: aws.Bool(true),
|
||||
NextContinuationToken: aws.String("next"),
|
||||
},
|
||||
{
|
||||
Contents: []types.Object{{Key: aws.String("root/b.txt"), Size: aws.Int64(2)}},
|
||||
IsTruncated: aws.Bool(false),
|
||||
},
|
||||
}
|
||||
backend := newTestBackend(t, "root", client)
|
||||
entries, err := storage.List(context.Background(), backend, "", storage.WalkOptions{Recursive: true})
|
||||
if err != nil {
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
paths := entryPaths(entries)
|
||||
if got, want := paths, []string{"a.txt", "b.txt"}; !equalStrings(got, want) {
|
||||
t.Fatalf("paths = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := client.tokens, []string{"", "next"}; !equalStrings(got, want) {
|
||||
t.Fatalf("tokens = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAnyStopsAfterFirstPage(t *testing.T) {
|
||||
client := newFakeClient(nil)
|
||||
client.listPages = []awss3.ListObjectsV2Output{
|
||||
{
|
||||
Contents: []types.Object{{Key: aws.String("root/a.txt"), Size: aws.Int64(1)}},
|
||||
IsTruncated: aws.Bool(true),
|
||||
NextContinuationToken: aws.String("next"),
|
||||
},
|
||||
{
|
||||
Contents: []types.Object{{Key: aws.String("root/b.txt"), Size: aws.Int64(2)}},
|
||||
IsTruncated: aws.Bool(false),
|
||||
},
|
||||
}
|
||||
backend := newTestBackend(t, "root", client)
|
||||
found, err := backend.HasAny(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("HasAny() error = %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("HasAny() = false, want true")
|
||||
}
|
||||
if got, want := len(client.tokens), 1; got != want {
|
||||
t.Fatalf("list calls = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkNonRecursiveUsesPrefixBoundary(t *testing.T) {
|
||||
client := newFakeClient(map[string]string{
|
||||
"base/dir/file.txt": "nested",
|
||||
"base/file.txt": "file",
|
||||
"baseball/file.txt": "wrong",
|
||||
})
|
||||
backend := newTestBackend(t, "base", client)
|
||||
entries, err := storage.List(context.Background(), backend, "", storage.WalkOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
paths := entryPaths(entries)
|
||||
if got, want := paths, []string{"dir", "file.txt"}; !equalStrings(got, want) {
|
||||
t.Fatalf("paths = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteManagedBundleDeletesOnlyManagedTargets(t *testing.T) {
|
||||
client := newFakeClient(map[string]string{
|
||||
"root/report.md": "report",
|
||||
"root/.distributor.json": "state",
|
||||
"root/keep.txt": "keep",
|
||||
})
|
||||
backend := newTestBackend(t, "root", client)
|
||||
err := backend.DeleteManagedBundle(context.Background(), "", []string{"report.md"}, storage.DeleteOptions{IgnoreMissing: true})
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteManagedBundle() error = %v", err)
|
||||
}
|
||||
if _, ok := client.objects["root/report.md"]; ok {
|
||||
t.Fatal("managed output still exists")
|
||||
}
|
||||
if _, ok := client.objects["root/.distributor.json"]; ok {
|
||||
t.Fatal("state file still exists")
|
||||
}
|
||||
if _, ok := client.objects["root/keep.txt"]; !ok {
|
||||
t.Fatal("unmanaged object was deleted")
|
||||
}
|
||||
if got, want := sortedStrings(client.deleteKeys), []string{"root/.distributor.json", "root/report.md"}; !equalStrings(got, want) {
|
||||
t.Fatalf("deleted keys = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestBackend(t *testing.T, prefix string, client *fakeClient) *Backend {
|
||||
t.Helper()
|
||||
if client == nil {
|
||||
client = newFakeClient(nil)
|
||||
}
|
||||
backend, err := NewWithClient(client, Options{
|
||||
Endpoint: "https://s3.example.com",
|
||||
Bucket: "reports",
|
||||
Prefix: prefix,
|
||||
Region: DefaultRegion,
|
||||
ForcePathStyle: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithClient() error = %v", err)
|
||||
}
|
||||
return backend
|
||||
}
|
||||
|
||||
type fakeClient struct {
|
||||
objects map[string]string
|
||||
contentTypes map[string]string
|
||||
listPages []awss3.ListObjectsV2Output
|
||||
tokens []string
|
||||
putKeys []string
|
||||
deleteKeys []string
|
||||
}
|
||||
|
||||
func newFakeClient(objects map[string]string) *fakeClient {
|
||||
copied := make(map[string]string)
|
||||
for key, value := range objects {
|
||||
copied[key] = value
|
||||
}
|
||||
return &fakeClient{
|
||||
objects: copied,
|
||||
contentTypes: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *fakeClient) HeadObject(ctx context.Context, input *awss3.HeadObjectInput, optFns ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, ok := c.objects[aws.ToString(input.Key)]
|
||||
if !ok {
|
||||
return nil, &types.NotFound{}
|
||||
}
|
||||
return &awss3.HeadObjectOutput{ContentLength: aws.Int64(int64(len(value)))}, nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) GetObject(ctx context.Context, input *awss3.GetObjectInput, optFns ...func(*awss3.Options)) (*awss3.GetObjectOutput, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, ok := c.objects[aws.ToString(input.Key)]
|
||||
if !ok {
|
||||
return nil, &types.NotFound{}
|
||||
}
|
||||
return &awss3.GetObjectOutput{
|
||||
Body: io.NopCloser(stringsReader(value)),
|
||||
ContentLength: aws.Int64(int64(len(value))),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) PutObject(ctx context.Context, input *awss3.PutObjectInput, optFns ...func(*awss3.Options)) (*awss3.PutObjectOutput, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := io.ReadAll(input.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := aws.ToString(input.Key)
|
||||
c.objects[key] = string(data)
|
||||
c.contentTypes[key] = aws.ToString(input.ContentType)
|
||||
c.putKeys = append(c.putKeys, key)
|
||||
return &awss3.PutObjectOutput{}, nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) ListObjectsV2(ctx context.Context, input *awss3.ListObjectsV2Input, optFns ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.tokens = append(c.tokens, aws.ToString(input.ContinuationToken))
|
||||
if len(c.listPages) > 0 {
|
||||
index := len(c.tokens) - 1
|
||||
if index >= len(c.listPages) {
|
||||
return &awss3.ListObjectsV2Output{IsTruncated: aws.Bool(false)}, nil
|
||||
}
|
||||
page := c.listPages[index]
|
||||
return &page, nil
|
||||
}
|
||||
return c.dynamicList(input), nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) DeleteObject(ctx context.Context, input *awss3.DeleteObjectInput, optFns ...func(*awss3.Options)) (*awss3.DeleteObjectOutput, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := aws.ToString(input.Key)
|
||||
delete(c.objects, key)
|
||||
c.deleteKeys = append(c.deleteKeys, key)
|
||||
return &awss3.DeleteObjectOutput{}, nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) dynamicList(input *awss3.ListObjectsV2Input) *awss3.ListObjectsV2Output {
|
||||
prefix := aws.ToString(input.Prefix)
|
||||
delimiter := aws.ToString(input.Delimiter)
|
||||
var contents []types.Object
|
||||
commonPrefixes := make(map[string]struct{})
|
||||
for key, value := range c.objects {
|
||||
if !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
remainder := strings.TrimPrefix(key, prefix)
|
||||
if delimiter != "" {
|
||||
if index := strings.Index(remainder, delimiter); index >= 0 {
|
||||
commonPrefixes[prefix+remainder[:index+1]] = struct{}{}
|
||||
continue
|
||||
}
|
||||
}
|
||||
contents = append(contents, types.Object{Key: aws.String(key), Size: aws.Int64(int64(len(value)))})
|
||||
}
|
||||
sort.Slice(contents, func(i, j int) bool { return aws.ToString(contents[i].Key) < aws.ToString(contents[j].Key) })
|
||||
prefixes := make([]types.CommonPrefix, 0, len(commonPrefixes))
|
||||
for prefix := range commonPrefixes {
|
||||
prefixes = append(prefixes, types.CommonPrefix{Prefix: aws.String(prefix)})
|
||||
}
|
||||
sort.Slice(prefixes, func(i, j int) bool { return aws.ToString(prefixes[i].Prefix) < aws.ToString(prefixes[j].Prefix) })
|
||||
return &awss3.ListObjectsV2Output{
|
||||
Contents: contents,
|
||||
CommonPrefixes: prefixes,
|
||||
IsTruncated: aws.Bool(false),
|
||||
}
|
||||
}
|
||||
|
||||
func stringsReader(value string) io.Reader {
|
||||
return strings.NewReader(value)
|
||||
}
|
||||
|
||||
func entryPaths(entries []storage.Entry) []string {
|
||||
paths := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
paths = append(paths, entry.Path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func sortedStrings(values []string) []string {
|
||||
copied := append([]string(nil), values...)
|
||||
sort.Strings(copied)
|
||||
return copied
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for index := range a {
|
||||
if a[index] != b[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
39
internal/adapters/s3/integration_test.go
Normal file
39
internal/adapters/s3/integration_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIntegrationS3BackendHasAny(t *testing.T) {
|
||||
endpoint := os.Getenv("DISTRIBUTOR_TEST_S3_ENDPOINT")
|
||||
bucket := os.Getenv("DISTRIBUTOR_TEST_S3_BUCKET")
|
||||
if endpoint == "" || bucket == "" {
|
||||
t.Skip("DISTRIBUTOR_TEST_S3_ENDPOINT and DISTRIBUTOR_TEST_S3_BUCKET are not set")
|
||||
}
|
||||
forcePathStyle := true
|
||||
if raw := os.Getenv("DISTRIBUTOR_TEST_S3_FORCE_PATH_STYLE"); raw != "" {
|
||||
parsed, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse DISTRIBUTOR_TEST_S3_FORCE_PATH_STYLE: %v", err)
|
||||
}
|
||||
forcePathStyle = parsed
|
||||
}
|
||||
backend, err := New(context.Background(), Options{
|
||||
Endpoint: endpoint,
|
||||
Bucket: bucket,
|
||||
Prefix: os.Getenv("DISTRIBUTOR_TEST_S3_PREFIX"),
|
||||
Region: os.Getenv("DISTRIBUTOR_TEST_S3_REGION"),
|
||||
ForcePathStyle: forcePathStyle,
|
||||
AccessKeyID: os.Getenv("DISTRIBUTOR_TEST_S3_ACCESS_KEY_ID"),
|
||||
SecretAccessKey: os.Getenv("DISTRIBUTOR_TEST_S3_SECRET_ACCESS_KEY"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
if _, err := backend.HasAny(context.Background(), ""); err != nil {
|
||||
t.Fatalf("HasAny(root) error = %v", err)
|
||||
}
|
||||
}
|
||||
42
internal/adapters/s3/options.go
Normal file
42
internal/adapters/s3/options.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
)
|
||||
|
||||
const BackendName = "s3"
|
||||
|
||||
const DefaultRegion = "us-east-1"
|
||||
|
||||
type Options struct {
|
||||
Endpoint string
|
||||
Bucket string
|
||||
Prefix string
|
||||
Region string
|
||||
ForcePathStyle bool
|
||||
AccessKeyID string
|
||||
SecretAccessKey string
|
||||
}
|
||||
|
||||
func (o Options) normalized() (Options, error) {
|
||||
if o.Endpoint == "" {
|
||||
return Options{}, fmt.Errorf("endpoint is required")
|
||||
}
|
||||
if o.Bucket == "" {
|
||||
return Options{}, fmt.Errorf("bucket is required")
|
||||
}
|
||||
if o.Region == "" {
|
||||
o.Region = DefaultRegion
|
||||
}
|
||||
o.Prefix = strings.Trim(o.Prefix, "/")
|
||||
if err := storage.ValidatePrefix(o.Prefix); err != nil {
|
||||
return Options{}, fmt.Errorf("prefix: %w", err)
|
||||
}
|
||||
if (o.AccessKeyID == "") != (o.SecretAccessKey == "") {
|
||||
return Options{}, fmt.Errorf("access key id and secret access key must be configured together")
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
Reference in New Issue
Block a user