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
22
go.mod
22
go.mod
|
|
@ -4,4 +4,24 @@ go 1.25.0
|
||||||
|
|
||||||
require golang.org/x/mod v0.35.0
|
require golang.org/x/mod v0.35.0
|
||||||
|
|
||||||
require github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.100.1 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
|
||||||
|
github.com/aws/smithy-go v1.25.1 // indirect
|
||||||
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||||
|
)
|
||||||
|
|
|
||||||
36
go.sum
36
go.sum
|
|
@ -1,3 +1,39 @@
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.100.1 h1:mxuT1xE+dI54NW3RkNjP8DUT5HXqbkiAFvfdyDFwE5c=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.100.1/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
|
||||||
|
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
|
||||||
|
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||||
|
|
|
||||||
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…
Reference in a new issue