Add S3-compatible storage backend

This commit is contained in:
2026-05-31 17:11:53 +00:00
parent 052aa8a64a
commit 14fa9c8000
27 changed files with 1334 additions and 45 deletions

View 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, &notFound) {
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)
}

View 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
}

View 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)
}
}

View 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
}

View File

@@ -6,6 +6,7 @@ import (
"strconv"
"gitea.maximumdirect.net/eric/distributor/internal/adapters/local"
s3adapter "gitea.maximumdirect.net/eric/distributor/internal/adapters/s3"
sshadapter "gitea.maximumdirect.net/eric/distributor/internal/adapters/ssh"
"gitea.maximumdirect.net/eric/distributor/internal/config"
"gitea.maximumdirect.net/eric/distributor/internal/storage"
@@ -20,6 +21,13 @@ const (
sshKeyFileKey = "ssh_key_file"
sshKnownHostsKey = "known_hosts"
sshHostKeyPolicyKey = "host_key_policy"
s3EndpointKey = "endpoint"
s3BucketKey = "bucket"
s3PrefixKey = "prefix"
s3RegionKey = "region"
s3ForcePathStyleKey = "force_path_style"
s3AccessKeyIDKey = "access_key_id"
s3SecretAccessKey = "secret_access_key"
)
type backendFactory struct {
@@ -54,21 +62,44 @@ func newBackendFactoryWithEnvironment(environment config.Environment) *backendFa
HostKeyPolicy: sshadapter.HostKeyPolicy(cfg[sshHostKeyPolicyKey]),
})
})
_ = registry.Register(config.BackendS3, func(ctx context.Context, cfg storage.OpenConfig) (storage.Backend, error) {
forcePathStyle, err := strconv.ParseBool(cfg[s3ForcePathStyleKey])
if err != nil {
return nil, fmt.Errorf("s3 force_path_style: %w", err)
}
return s3adapter.New(ctx, s3adapter.Options{
Endpoint: cfg[s3EndpointKey],
Bucket: cfg[s3BucketKey],
Prefix: cfg[s3PrefixKey],
Region: cfg[s3RegionKey],
ForcePathStyle: forcePathStyle,
AccessKeyID: cfg[s3AccessKeyIDKey],
SecretAccessKey: cfg[s3SecretAccessKey],
})
})
return &backendFactory{registry: registry, environment: environment}
}
func (f *backendFactory) openSource(ctx context.Context, source config.Backend) (storage.Backend, error) {
if source.Backend != config.BackendLocal && source.Backend != config.BackendSSH {
if source.Backend != config.BackendLocal && source.Backend != config.BackendSSH && source.Backend != config.BackendS3 {
return nil, fmt.Errorf("source backend %s is not implemented for execution", source.Backend)
}
return f.registry.Open(ctx, source.Backend, sourceOpenConfig(source))
openConfig, err := f.sourceOpenConfig(source)
if err != nil {
return nil, err
}
return f.registry.Open(ctx, source.Backend, openConfig)
}
func (f *backendFactory) openDestination(ctx context.Context, destination config.Destination) (storage.Backend, error) {
if destination.Backend != config.BackendLocal && destination.Backend != config.BackendSSH {
if destination.Backend != config.BackendLocal && destination.Backend != config.BackendSSH && destination.Backend != config.BackendS3 {
return nil, fmt.Errorf("backend %s is not implemented for execution", destination.Backend)
}
return f.registry.Open(ctx, destination.Backend, destinationOpenConfig(destination))
openConfig, err := f.destinationOpenConfig(destination)
if err != nil {
return nil, err
}
return f.registry.Open(ctx, destination.Backend, openConfig)
}
func (f *backendFactory) openLocalPath(ctx context.Context, path string) (storage.Backend, error) {
@@ -79,6 +110,43 @@ func (f *backendFactory) resolveCredentials(creds config.Credentials) (config.Re
return f.environment.ResolveCredentials(creds)
}
func (f *backendFactory) sourceOpenConfig(source config.Backend) (storage.OpenConfig, error) {
cfg := sourceOpenConfig(source)
if source.Backend == config.BackendS3 {
if err := f.addS3Config(cfg, source.Endpoint, source.Bucket, source.Prefix, source.Region, source.ForcePath, source.Creds); err != nil {
return nil, err
}
}
return cfg, nil
}
func (f *backendFactory) destinationOpenConfig(destination config.Destination) (storage.OpenConfig, error) {
cfg := destinationOpenConfig(destination)
if destination.Backend == config.BackendS3 {
if err := f.addS3Config(cfg, destination.Endpoint, destination.Bucket, destination.Prefix, destination.Region, destination.ForcePath, destination.Creds); err != nil {
return nil, err
}
}
return cfg, nil
}
func (f *backendFactory) addS3Config(cfg storage.OpenConfig, endpoint, bucket, prefix, region string, forcePath *bool, creds config.Credentials) error {
cfg[s3EndpointKey] = endpoint
cfg[s3BucketKey] = bucket
cfg[s3PrefixKey] = prefix
cfg[s3RegionKey] = region
cfg[s3ForcePathStyleKey] = strconv.FormatBool(config.ForcePathStyle(forcePath))
if creds.AccessKeyIDEnv != "" || creds.SecretAccessKeyEnv != "" {
resolved, err := f.resolveCredentials(creds)
if err != nil {
return err
}
cfg[s3AccessKeyIDKey] = resolved.AccessKeyID
cfg[s3SecretAccessKey] = resolved.SecretAccessKey
}
return nil
}
func sourceOpenConfig(source config.Backend) storage.OpenConfig {
cfg := storage.OpenConfig{storagePathKey: source.Path}
if source.Backend == config.BackendSSH {

View File

@@ -110,11 +110,9 @@ func TestBackendFactoryOpensSSHDestinationWithRegisteredOpener(t *testing.T) {
func TestBackendFactoryRejectsUnsupportedSource(t *testing.T) {
factory := newBackendFactory()
_, err := factory.openSource(context.Background(), config.Backend{
Backend: config.BackendS3,
Endpoint: "https://s3.example.com",
Bucket: "reports",
Backend: "ftp",
})
if err == nil || !strings.Contains(err.Error(), "source backend s3 is not implemented for execution") {
if err == nil || !strings.Contains(err.Error(), "source backend ftp is not implemented for execution") {
t.Fatalf("openSource() error = %v, want not implemented", err)
}
}
@@ -122,15 +120,85 @@ func TestBackendFactoryRejectsUnsupportedSource(t *testing.T) {
func TestBackendFactoryRejectsUnsupportedDestination(t *testing.T) {
factory := newBackendFactory()
_, err := factory.openDestination(context.Background(), config.Destination{
Backend: config.BackendS3,
Endpoint: "https://s3.example.com",
Bucket: "reports",
Backend: "ftp",
})
if err == nil || !strings.Contains(err.Error(), "backend s3 is not implemented for execution") {
if err == nil || !strings.Contains(err.Error(), "backend ftp is not implemented for execution") {
t.Fatalf("openDestination() error = %v, want not implemented", err)
}
}
func TestBackendFactoryOpensS3DestinationWithRegisteredOpener(t *testing.T) {
factory := &backendFactory{
registry: storage.NewRegistry(),
environment: config.NewEnvironment(nil, func(string) (string, bool) { return "", false }),
}
var got storage.OpenConfig
if err := factory.registry.Register(config.BackendS3, func(ctx context.Context, cfg storage.OpenConfig) (storage.Backend, error) {
got = cfg
return fake.New(), nil
}); err != nil {
t.Fatalf("Register() error = %v", err)
}
forcePathStyle := false
backend, err := factory.openDestination(context.Background(), config.Destination{
Backend: config.BackendS3,
Endpoint: "https://s3.example.com",
Bucket: "reports",
Prefix: "archive",
Region: config.DefaultS3Region,
ForcePath: &forcePathStyle,
})
if err != nil {
t.Fatalf("openDestination() error = %v", err)
}
if backend == nil {
t.Fatal("openDestination() backend = nil")
}
assertOpenConfig(t, got, map[string]string{
s3EndpointKey: "https://s3.example.com",
s3BucketKey: "reports",
s3PrefixKey: "archive",
s3RegionKey: config.DefaultS3Region,
s3ForcePathStyleKey: "false",
})
}
func TestBackendFactoryResolvesS3CredentialsThroughSecretsAwareEnvironment(t *testing.T) {
factory := &backendFactory{
registry: storage.NewRegistry(),
environment: config.NewEnvironment(map[string]string{
"ACCESS_KEY_ID": "secret-access",
"SECRET_ACCESS_KEY": "secret-secret",
}, func(string) (string, bool) { return "", false }),
}
var got storage.OpenConfig
if err := factory.registry.Register(config.BackendS3, func(ctx context.Context, cfg storage.OpenConfig) (storage.Backend, error) {
got = cfg
return fake.New(), nil
}); err != nil {
t.Fatalf("Register() error = %v", err)
}
forcePathStyle := true
_, err := factory.openSource(context.Background(), config.Backend{
Backend: config.BackendS3,
Endpoint: "https://s3.example.com",
Bucket: "reports",
Region: config.DefaultS3Region,
ForcePath: &forcePathStyle,
Creds: config.Credentials{
AccessKeyIDEnv: "ACCESS_KEY_ID",
SecretAccessKeyEnv: "SECRET_ACCESS_KEY",
},
})
if err != nil {
t.Fatalf("openSource() error = %v", err)
}
assertOpenConfig(t, got, map[string]string{
s3AccessKeyIDKey: "secret-access",
s3SecretAccessKey: "secret-secret",
})
}
func TestBackendFactoryResolvesCredentialsThroughEnvironment(t *testing.T) {
factory := newBackendFactoryWithEnvironment(config.NewEnvironment(map[string]string{
"ACCESS_KEY_ID": "secret-access",

View File

@@ -28,7 +28,7 @@ type Destination struct {
Bucket string `yaml:"bucket"`
Prefix string `yaml:"prefix"`
Region string `yaml:"region"`
ForcePath bool `yaml:"force_path_style"`
ForcePath *bool `yaml:"force_path_style"`
Creds Credentials `yaml:"credentials"`
SSH SSH `yaml:",inline"`
Publish *PublishPolicy `yaml:"publish"`
@@ -47,7 +47,7 @@ type Backend struct {
Bucket string `yaml:"bucket"`
Prefix string `yaml:"prefix"`
Region string `yaml:"region"`
ForcePath bool `yaml:"force_path_style"`
ForcePath *bool `yaml:"force_path_style"`
Creds Credentials `yaml:"credentials"`
SSH SSH `yaml:",inline"`
}

View File

@@ -22,6 +22,8 @@ const (
TransformModeSidecar = "sidecar"
)
const DefaultS3Region = "us-east-1"
func ApplyDefaults(cfg *Config) {
for pipelineIndex := range cfg.Pipelines {
pipeline := &cfg.Pipelines[pipelineIndex]
@@ -60,6 +62,9 @@ func applyBackendDefaults(backend *Backend) {
backend.SSH.HostKeyPolicy = HostKeyPolicyAcceptNew
}
}
if backend.Backend == BackendS3 {
applyS3Defaults(&backend.Region, &backend.Prefix, &backend.ForcePath)
}
}
func applyDestinationDefaults(destination *Destination) {
@@ -71,4 +76,18 @@ func applyDestinationDefaults(destination *Destination) {
destination.SSH.HostKeyPolicy = HostKeyPolicyAcceptNew
}
}
if destination.Backend == BackendS3 {
applyS3Defaults(&destination.Region, &destination.Prefix, &destination.ForcePath)
}
}
func applyS3Defaults(region, prefix *string, forcePath **bool) {
if *region == "" {
*region = DefaultS3Region
}
*prefix = NormalizeS3Prefix(*prefix)
if *forcePath == nil {
defaultForcePath := true
*forcePath = &defaultForcePath
}
}

View File

@@ -155,6 +155,61 @@ pipelines:
}
}
func TestLoadFileDefaultsS3Config(t *testing.T) {
cfg := loadConfig(t, `
pipelines:
- id: s3-defaults
source:
backend: s3
endpoint: http://127.0.0.1:9000
bucket: source
prefix: /incoming/reports/
destinations:
- id: archive
backend: s3
endpoint: http://127.0.0.1:9000
bucket: destination
`)
source := cfg.Pipelines[0].Source
if got, want := source.Region, DefaultS3Region; got != want {
t.Fatalf("source region = %q, want %q", got, want)
}
if got, want := source.Prefix, "incoming/reports"; got != want {
t.Fatalf("source prefix = %q, want %q", got, want)
}
if !ForcePathStyle(source.ForcePath) {
t.Fatal("source force_path_style = false, want true")
}
destination := cfg.Pipelines[0].Destinations[0]
if got, want := destination.Region, DefaultS3Region; got != want {
t.Fatalf("destination region = %q, want %q", got, want)
}
if !ForcePathStyle(destination.ForcePath) {
t.Fatal("destination force_path_style = false, want true")
}
}
func TestLoadFilePreservesExplicitS3ForcePathStyleFalse(t *testing.T) {
cfg := loadConfig(t, `
pipelines:
- id: s3-force-path
source:
backend: s3
endpoint: https://s3.example.com
bucket: source
force_path_style: false
destinations:
- id: archive
backend: local
path: /archive
`)
if ForcePathStyle(cfg.Pipelines[0].Source.ForcePath) {
t.Fatal("force_path_style = true, want explicit false")
}
}
func TestLoadFileRejectsDuplicatePipelineIDs(t *testing.T) {
assertLoadError(t, `
pipelines:
@@ -214,6 +269,19 @@ func TestLoadFileRejectsMissingRequiredFields(t *testing.T) {
}
}
func TestLoadFileRejectsInvalidS3Config(t *testing.T) {
tests := map[string]string{
"prefix traversal": `pipelines: [{id: reports, source: {backend: s3, endpoint: "https://s3.example.com", bucket: source, prefix: "../reports"}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
"prefix backslash": `pipelines: [{id: reports, source: {backend: s3, endpoint: "https://s3.example.com", bucket: source, prefix: 'a\b'}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
"partial creds": `pipelines: [{id: reports, source: {backend: s3, endpoint: "https://s3.example.com", bucket: source, credentials: {access_key_id_env: ACCESS_KEY_ID}}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
}
for name, body := range tests {
t.Run(name, func(t *testing.T) {
assertLoadError(t, body, "")
})
}
}
func TestLoadFileDefaultsSSHConfig(t *testing.T) {
cfg := loadConfig(t, `
pipelines:
@@ -398,6 +466,7 @@ func TestExampleConfigsLoad(t *testing.T) {
"../../examples/local-html.yml",
"../../examples/fan-out.yml",
"../../examples/ssh-destination.yml",
"../../examples/s3-destination.yml",
} {
t.Run(path, func(t *testing.T) {
if _, err := LoadFile(path); err != nil {

19
internal/config/s3.go Normal file
View File

@@ -0,0 +1,19 @@
package config
import (
"strings"
"gitea.maximumdirect.net/eric/distributor/internal/storage"
)
func NormalizeS3Prefix(prefix string) string {
return strings.Trim(prefix, "/")
}
func ValidateS3Prefix(prefix string) error {
return storage.ValidatePrefix(prefix)
}
func ForcePathStyle(value *bool) bool {
return value == nil || *value
}

View File

@@ -80,6 +80,9 @@ func (e Environment) required(name string) (string, error) {
if !ok {
return "", fmt.Errorf("credential environment variable %s is not set", name)
}
if value == "" {
return "", fmt.Errorf("credential environment variable %s is empty", name)
}
return value, nil
}

View File

@@ -207,6 +207,17 @@ func TestResolveCredentialsMissingReferenceFailsWithoutSecretValue(t *testing.T)
}
}
func TestResolveCredentialsRejectsEmptyReferencedValue(t *testing.T) {
env := NewEnvironment(map[string]string{"EMPTY": ""}, emptyLookup)
_, err := env.ResolveCredentials(Credentials{AccessKeyIDEnv: "EMPTY"})
if err == nil {
t.Fatal("ResolveCredentials() error = nil, want error")
}
if !strings.Contains(err.Error(), "EMPTY") || !strings.Contains(err.Error(), "empty") {
t.Fatalf("ResolveCredentials() error = %q, want empty variable context", err)
}
}
func writeSecret(t *testing.T, directory, name, value string) {
t.Helper()
if err := os.WriteFile(filepath.Join(directory, name), []byte(value), 0o600); err != nil {

View File

@@ -69,14 +69,14 @@ func Validate(cfg Config) error {
}
func validateSourceBackend(errs ValidationErrors, context string, backend Backend) ValidationErrors {
return validateBackend(errs, context, backend.Backend, backend.Host, backend.Port, backend.Path, backend.URI, backend.Endpoint, backend.Bucket, backend.SSH.HostKeyPolicy)
return validateBackend(errs, context, backend.Backend, backend.Host, backend.Port, backend.Path, backend.URI, backend.Endpoint, backend.Bucket, backend.Prefix, backend.SSH.HostKeyPolicy, backend.Creds)
}
func validateDestinationBackend(errs ValidationErrors, context string, destination Destination) ValidationErrors {
return validateBackend(errs, context, destination.Backend, destination.Host, destination.Port, destination.Path, destination.URI, destination.Endpoint, destination.Bucket, destination.SSH.HostKeyPolicy)
return validateBackend(errs, context, destination.Backend, destination.Host, destination.Port, destination.Path, destination.URI, destination.Endpoint, destination.Bucket, destination.Prefix, destination.SSH.HostKeyPolicy, destination.Creds)
}
func validateBackend(errs ValidationErrors, context, backend, host string, port int, path, uri, endpoint, bucket string, hostKeyPolicy HostKeyPolicy) ValidationErrors {
func validateBackend(errs ValidationErrors, context, backend, host string, port int, path, uri, endpoint, bucket, prefix string, hostKeyPolicy HostKeyPolicy, creds Credentials) ValidationErrors {
switch backend {
case "":
errs = append(errs, context+".backend is required")
@@ -112,6 +112,12 @@ func validateBackend(errs ValidationErrors, context, backend, host string, port
if bucket == "" {
errs = append(errs, context+".bucket is required for s3 backend")
}
if err := ValidateS3Prefix(prefix); err != nil {
errs = append(errs, context+".prefix must be a clean relative slash-separated path")
}
if (creds.AccessKeyIDEnv == "") != (creds.SecretAccessKeyEnv == "") {
errs = append(errs, context+".credentials.access_key_id_env and credentials.secret_access_key_env must be configured together")
}
default:
errs = append(errs, context+".backend "+backend+" is unsupported")
}