Add S3 upload with key templating and custom endpoint
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
708cc0c864
commit
fc259f2a28
4 changed files with 215 additions and 1 deletions
70
internal/upload/upload.go
Normal file
70
internal/upload/upload.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package upload
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
)
|
||||
|
||||
// PutObjectAPI is the subset of *s3.Client used here. Tests inject a fake.
|
||||
type PutObjectAPI interface {
|
||||
PutObject(ctx context.Context, in *s3.PutObjectInput, opts ...func(*s3.Options)) (*s3.PutObjectOutput, error)
|
||||
}
|
||||
|
||||
// Opts configures a single upload.
|
||||
type Opts struct {
|
||||
Bucket string
|
||||
Key string
|
||||
FilePath string
|
||||
}
|
||||
|
||||
// RenderKey substitutes {version} and {filename} placeholders.
|
||||
func RenderKey(template, version, filename string) string {
|
||||
r := strings.NewReplacer("{version}", version, "{filename}", filename)
|
||||
return r.Replace(template)
|
||||
}
|
||||
|
||||
// Upload PUTs FilePath at s3://Bucket/Key and returns the s3:// URL.
|
||||
func Upload(ctx context.Context, c PutObjectAPI, o Opts) (string, error) {
|
||||
f, err := os.Open(o.FilePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open %s: %w", o.FilePath, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := c.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(o.Bucket),
|
||||
Key: aws.String(o.Key),
|
||||
Body: f,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("s3 put: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("s3://%s/%s", o.Bucket, o.Key), nil
|
||||
}
|
||||
|
||||
// WriteFileForTest is exposed for use from this package's tests.
|
||||
func WriteFileForTest(path string, b []byte) error {
|
||||
return os.WriteFile(path, b, 0o600)
|
||||
}
|
||||
|
||||
// NewClient builds an *s3.Client honouring an optional custom endpoint
|
||||
// for S3-compatible storage.
|
||||
func NewClient(ctx context.Context, region, endpointURL string) (*s3.Client, error) {
|
||||
cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aws config: %w", err)
|
||||
}
|
||||
opts := []func(*s3.Options){}
|
||||
if endpointURL != "" {
|
||||
opts = append(opts, func(o *s3.Options) {
|
||||
o.BaseEndpoint = aws.String(endpointURL)
|
||||
o.UsePathStyle = true
|
||||
})
|
||||
}
|
||||
return s3.NewFromConfig(cfg, opts...), nil
|
||||
}
|
||||
88
internal/upload/upload_test.go
Normal file
88
internal/upload/upload_test.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package upload_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/leonmika/wails-release/internal/upload"
|
||||
)
|
||||
|
||||
func TestRenderKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
template string
|
||||
version string
|
||||
filename string
|
||||
want string
|
||||
}{
|
||||
{"releases/{version}/{filename}", "1.2.3", "App.app.zip", "releases/1.2.3/App.app.zip"},
|
||||
{"{filename}", "1.0.0", "App.app.zip", "App.app.zip"},
|
||||
{"app/{version}-{filename}", "1.2.3", "App.app.zip", "app/1.2.3-App.app.zip"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := upload.RenderKey(c.template, c.version, c.filename)
|
||||
if got != c.want {
|
||||
t.Fatalf("template %q: got %q want %q", c.template, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakePut struct {
|
||||
calls []*s3.PutObjectInput
|
||||
}
|
||||
|
||||
func (f *fakePut) PutObject(ctx context.Context, in *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
|
||||
f.calls = append(f.calls, in)
|
||||
return &s3.PutObjectOutput{}, nil
|
||||
}
|
||||
|
||||
func TestUpload_CallsPutObjectWithCorrectInputs(t *testing.T) {
|
||||
tmp := writeTempFile(t, "hello")
|
||||
c := &fakePut{}
|
||||
|
||||
url, err := upload.Upload(context.Background(), c, upload.Opts{
|
||||
Bucket: "my-bucket",
|
||||
Key: "releases/1.2.3/App.app.zip",
|
||||
FilePath: tmp,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
}
|
||||
if url != "s3://my-bucket/releases/1.2.3/App.app.zip" {
|
||||
t.Fatalf("url got %q", url)
|
||||
}
|
||||
if len(c.calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(c.calls))
|
||||
}
|
||||
if aws.ToString(c.calls[0].Bucket) != "my-bucket" || aws.ToString(c.calls[0].Key) != "releases/1.2.3/App.app.zip" {
|
||||
t.Fatalf("bucket/key wrong: %+v", c.calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpload_FailsWhenFileMissing(t *testing.T) {
|
||||
_, err := upload.Upload(context.Background(), &fakePut{}, upload.Opts{
|
||||
Bucket: "b",
|
||||
Key: "k",
|
||||
FilePath: "/does/not/exist",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "open") {
|
||||
t.Fatalf("expected open error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeTempFile creates a small temp file and returns its path.
|
||||
func writeTempFile(t *testing.T, contents string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := dir + "/blob"
|
||||
if err := writeAll(path, contents); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func writeAll(path, s string) error {
|
||||
return upload.WriteFileForTest(path, []byte(s))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue