@specific.dev/spectest 0.129.0 → 0.130.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/s3-runtime/README.md +33 -0
- package/dist/components/s3-runtime/go.mod +11 -0
- package/dist/components/s3-runtime/go.sum +54 -0
- package/dist/components/s3-runtime/main.go +77 -0
- package/dist/components/s3-runtime/metadata.patch +12 -0
- package/dist/components/s3.d.ts +4 -5
- package/dist/components/s3.js +24 -14
- package/package.json +1 -1
- package/src/components/s3-docker.test.ts +159 -0
- package/src/components/s3-runtime/README.md +33 -0
- package/src/components/s3-runtime/go.mod +11 -0
- package/src/components/s3-runtime/go.sum +54 -0
- package/src/components/s3-runtime/main.go +77 -0
- package/src/components/s3-runtime/metadata.patch +12 -0
- package/src/components/s3.test.ts +36 -0
- package/src/components/s3.ts +25 -16
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Native S3 component runtime
|
|
2
|
+
|
|
3
|
+
GoFakeS3 is pinned by `go.mod`/`go.sum`. `s3.ts` embeds these assets into a constant
|
|
4
|
+
multi-stage Dockerfile, builds for the worker's native architecture, and ships
|
|
5
|
+
only the static binary and dependency licenses. The Go builder is pinned to a
|
|
6
|
+
multi-architecture digest. No project files or options enter the image build.
|
|
7
|
+
|
|
8
|
+
The entrypoint retains the component's port 9090, path-style addressing, no
|
|
9
|
+
automatic buckets, no signature/date validation, and permissive browser CORS.
|
|
10
|
+
The memory backend supports pagination and versioning. Full VM snapshots clone
|
|
11
|
+
its buckets, versions and pending multipart uploads; restarting the process
|
|
12
|
+
starts empty, matching S3Mock's default temporary store.
|
|
13
|
+
|
|
14
|
+
`metadata.patch` preserves Cache-Control, Content-Language and Expires on object
|
|
15
|
+
uploads, multipart initiation and copies. Upstream currently drops these headers
|
|
16
|
+
in `metadataHeaders`. The patch applies to the vendored, checksum-verified module;
|
|
17
|
+
a failed patch stops the build. Recheck it when changing the upstream pin.
|
|
18
|
+
|
|
19
|
+
From `sdk`, run the real-image contract on a Linux Docker host:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
SPECTEST_TEST_S3_DOCKER=1 bun test src/components/s3-docker.test.ts
|
|
23
|
+
SPECTEST_TEST_S3_DOCKER=1 SPECTEST_TEST_S3_BASELINE=1 bun test src/components/s3-docker.test.ts
|
|
24
|
+
bun test src/components/s3.test.ts src/components/internal-fetch.test.ts
|
|
25
|
+
bun run build
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The baseline uses S3Mock 5.1.0. The contract covers helpers and recording, setup,
|
|
29
|
+
credentials, HTTPS with preserved Host, browser CORS, unsigned/stale signatures,
|
|
30
|
+
metadata, copy/range requests, multipart, paginated listing, versioning, and
|
|
31
|
+
fresh-process lifecycle. It does not prove every S3 API is interchangeable:
|
|
32
|
+
GoFakeS3 lacks some S3Mock features, including tagging, lifecycle and retention.
|
|
33
|
+
Java/Spring/S3Mock-specific environment settings are no longer interpreted.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module spectest-s3
|
|
2
|
+
|
|
3
|
+
go 1.24
|
|
4
|
+
|
|
5
|
+
require github.com/johannesboyne/gofakes3 v1.2.1-0.20261011201219-0016ef2bf153
|
|
6
|
+
|
|
7
|
+
require (
|
|
8
|
+
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
|
|
9
|
+
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
|
|
10
|
+
golang.org/x/tools v0.8.0 // indirect
|
|
11
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
|
2
|
+
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
|
3
|
+
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
|
4
|
+
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
|
5
|
+
github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM=
|
|
6
|
+
github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ=
|
|
7
|
+
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.75 h1:S61/E3N01oral6B3y9hZ2E1iFDqCZPPOBoBQretCnBI=
|
|
8
|
+
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.75/go.mod h1:bDMQbkI1vJbNjnvJYpPTSNYBkI/VIv18ngWb/K84tkk=
|
|
9
|
+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
|
10
|
+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
|
11
|
+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
|
12
|
+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
|
13
|
+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
|
|
14
|
+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
|
|
15
|
+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
|
16
|
+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
|
17
|
+
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
|
|
18
|
+
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
|
|
19
|
+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
|
|
20
|
+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
|
21
|
+
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
|
|
22
|
+
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
|
|
23
|
+
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
|
|
24
|
+
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
|
|
25
|
+
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
|
26
|
+
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
|
27
|
+
github.com/cevatbarisyilmaz/ara v0.0.4 h1:SGH10hXpBJhhTlObuZzTuFn1rrdmjQImITXnZVPSodc=
|
|
28
|
+
github.com/cevatbarisyilmaz/ara v0.0.4/go.mod h1:BfFOxnUd6Mj6xmcvRxHN3Sr21Z1T3U2MYkYOmoQe4Ts=
|
|
29
|
+
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
|
30
|
+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
31
|
+
github.com/johannesboyne/gofakes3 v1.2.1-0.20261011201219-0016ef2bf153 h1:ZaLBZJfUnKBlrPu0AX0T/i1N9BNU54j8sl9mCLzmpI0=
|
|
32
|
+
github.com/johannesboyne/gofakes3 v1.2.1-0.20261011201219-0016ef2bf153/go.mod h1:UHhRZRod9rENGFrUWTYnQHZqlNgSmjOq8DaD/ATQYRM=
|
|
33
|
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
34
|
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
35
|
+
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
|
|
36
|
+
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
|
|
37
|
+
github.com/spf13/afero v1.2.1 h1:qgMbHoJbPbw579P+1zVY+6n4nIFuIchaIjzZ/I/Yq8M=
|
|
38
|
+
github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
|
|
39
|
+
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
|
|
40
|
+
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
|
41
|
+
go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
|
|
42
|
+
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
|
43
|
+
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d h1:Ns9kd1Rwzw7t0BR8XMphenji4SmIoNZPn8zhYmaVKP8=
|
|
44
|
+
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d/go.mod h1:92Uoe3l++MlthCm+koNi0tcUCX3anayogF0Pa/sp24k=
|
|
45
|
+
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
|
|
46
|
+
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
47
|
+
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
|
48
|
+
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
|
49
|
+
golang.org/x/tools v0.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y=
|
|
50
|
+
golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4=
|
|
51
|
+
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU=
|
|
52
|
+
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
|
|
53
|
+
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
|
54
|
+
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The component's native entrypoint. Keep its defaults compatible with S3Mock;
|
|
2
|
+
// the upstream CLI enforces clock skew and has different browser CORS defaults.
|
|
3
|
+
package main
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"log"
|
|
7
|
+
"net/http"
|
|
8
|
+
|
|
9
|
+
"github.com/johannesboyne/gofakes3"
|
|
10
|
+
"github.com/johannesboyne/gofakes3/backend/s3mem"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func main() {
|
|
14
|
+
// VM snapshots include RAM: buckets, versions and in-flight multipart state
|
|
15
|
+
// fork together. A fresh process starts empty, as S3Mock did by default.
|
|
16
|
+
// Unlike the Bolt backend, s3mem also supports bucket versioning.
|
|
17
|
+
backend := s3mem.New()
|
|
18
|
+
server := gofakes3.New(backend,
|
|
19
|
+
gofakes3.WithTimeSkewLimit(0), // S3Mock accepts arbitrary/expired signatures.
|
|
20
|
+
gofakes3.WithHostBucket(false),
|
|
21
|
+
gofakes3.WithAutoBucket(false),
|
|
22
|
+
gofakes3.WithLogger(gofakes3.GlobalLog()),
|
|
23
|
+
).Server()
|
|
24
|
+
log.Fatal(http.ListenAndServe(":9090", cors(server)))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Preserve S3Mock's permissive browser uploads, including arbitrary metadata
|
|
28
|
+
// headers. GoFakeS3's default allow-list rejects custom x-amz-meta-* headers and
|
|
29
|
+
// only exposes ETag on preflights, hiding it from actual browser responses.
|
|
30
|
+
func cors(next http.Handler) http.Handler {
|
|
31
|
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
32
|
+
origin := r.Header.Get("Origin")
|
|
33
|
+
w.Header().Add("Vary", "Origin")
|
|
34
|
+
w.Header().Add("Vary", "Access-Control-Request-Method")
|
|
35
|
+
w.Header().Add("Vary", "Access-Control-Request-Headers")
|
|
36
|
+
if origin != "" && r.Method == "OPTIONS" && r.Header.Get("Access-Control-Request-Method") != "" {
|
|
37
|
+
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
38
|
+
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
39
|
+
w.Header().Set("Access-Control-Allow-Methods", r.Header.Get("Access-Control-Request-Method"))
|
|
40
|
+
w.Header().Set("Access-Control-Allow-Headers", r.Header.Get("Access-Control-Request-Headers"))
|
|
41
|
+
w.WriteHeader(http.StatusOK)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
writer := &corsWriter{ResponseWriter: w, hasOrigin: origin != ""}
|
|
45
|
+
next.ServeHTTP(writer, r)
|
|
46
|
+
if !writer.written {
|
|
47
|
+
writer.WriteHeader(http.StatusOK)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type corsWriter struct {
|
|
53
|
+
http.ResponseWriter
|
|
54
|
+
hasOrigin bool
|
|
55
|
+
written bool
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func (w *corsWriter) WriteHeader(status int) {
|
|
59
|
+
if w.written {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
w.written = true
|
|
63
|
+
if w.hasOrigin {
|
|
64
|
+
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
65
|
+
w.Header().Set("Access-Control-Expose-Headers", "*")
|
|
66
|
+
} else {
|
|
67
|
+
w.Header().Del("Access-Control-Allow-Origin")
|
|
68
|
+
}
|
|
69
|
+
w.ResponseWriter.WriteHeader(status)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func (w *corsWriter) Write(body []byte) (int, error) {
|
|
73
|
+
if !w.written {
|
|
74
|
+
w.WriteHeader(http.StatusOK)
|
|
75
|
+
}
|
|
76
|
+
return w.ResponseWriter.Write(body)
|
|
77
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
--- a/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
|
|
2
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
|
|
3
|
+
@@ -1191,6 +1191,9 @@
|
|
4
|
+
for hk, hv := range headers {
|
|
5
|
+
if strings.HasPrefix(hk, "X-Amz-") ||
|
|
6
|
+
hk == "Content-Type" ||
|
|
7
|
+
+ hk == "Cache-Control" ||
|
|
8
|
+
+ hk == "Content-Language" ||
|
|
9
|
+
+ hk == "Expires" ||
|
|
10
|
+
hk == "Content-Disposition" ||
|
|
11
|
+
hk == "Content-Encoding" {
|
|
12
|
+
meta[hk] = hv[0]
|
package/dist/components/s3.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { type S3ClientLike } from "../s3.js";
|
|
2
2
|
export interface S3Options {
|
|
3
3
|
/**
|
|
4
|
-
* Buckets to create on first boot. The
|
|
5
|
-
* env is unreliable across versions, so the component creates them in `setup`
|
|
4
|
+
* Buckets to create on first boot. The component creates them in `setup`
|
|
6
5
|
* (a plain CreateBucket `PUT`) — captured into the warm snapshot, never
|
|
7
6
|
* re-run on warm starts/forks.
|
|
8
7
|
*/
|
|
@@ -42,7 +41,7 @@ export interface S3Helpers {
|
|
|
42
41
|
}
|
|
43
42
|
/**
|
|
44
43
|
* A ready-to-use, S3-compatible object store (backed by
|
|
45
|
-
* [
|
|
44
|
+
* [GoFakeS3](https://github.com/johannesboyne/gofakes3)), with an instrumented S3
|
|
46
45
|
* client on `ctx.svc.<key>.client`. Drop into `environment.services`:
|
|
47
46
|
*
|
|
48
47
|
* ```ts
|
|
@@ -90,8 +89,8 @@ export declare function s3(opts?: S3Options): {
|
|
|
90
89
|
}[] | undefined;
|
|
91
90
|
env?: Record<string, string> | undefined;
|
|
92
91
|
image: {
|
|
93
|
-
type: "
|
|
94
|
-
|
|
92
|
+
type: "dockerfile";
|
|
93
|
+
content: string;
|
|
95
94
|
};
|
|
96
95
|
ports: number[];
|
|
97
96
|
};
|
package/dist/components/s3.js
CHANGED
|
@@ -1,21 +1,31 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import { rawFetch } from "../harness/raw-fetch.js";
|
|
2
3
|
import { S3Client } from "../s3.js";
|
|
3
|
-
//
|
|
4
|
-
// is
|
|
5
|
-
//
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
4
|
+
// Build once per architecture/cache from pinned source: upstream's Docker Hub
|
|
5
|
+
// image is amd64-only. Only the static native server ships in the runtime image.
|
|
6
|
+
// Keep the Dockerfile independent of options so stores share the build cache.
|
|
7
|
+
const S3_DOCKERFILE = `
|
|
8
|
+
FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS build
|
|
9
|
+
RUN apk add --no-cache patch
|
|
10
|
+
WORKDIR /src
|
|
11
|
+
${["go.mod", "go.sum", "main.go", "metadata.patch"].map((file) => {
|
|
12
|
+
const data = readFileSync(new URL(`./s3-runtime/${file}`, import.meta.url)).toString("base64");
|
|
13
|
+
return `RUN printf '%s' '${data}' | base64 -d > ${file}`;
|
|
14
|
+
}).join("\n")}
|
|
15
|
+
RUN go mod vendor && patch -p1 < metadata.patch
|
|
16
|
+
RUN CGO_ENABLED=0 go build -mod=vendor -trimpath -ldflags='-s -w' -o /s3-server .
|
|
17
|
+
FROM scratch
|
|
18
|
+
COPY --from=build /s3-server /s3-server
|
|
19
|
+
COPY --from=build /src/vendor/github.com/johannesboyne/gofakes3/LICENSE /licenses/gofakes3
|
|
20
|
+
COPY --from=build /src/vendor/github.com/ryszard/goskiplist/LICENSE /licenses/goskiplist
|
|
21
|
+
EXPOSE 9090
|
|
22
|
+
ENTRYPOINT ["/s3-server"]
|
|
23
|
+
`;
|
|
24
|
+
// Public endpoint stays fixed across backing-server changes.
|
|
15
25
|
const S3_PORT = 9090;
|
|
16
26
|
/**
|
|
17
27
|
* A ready-to-use, S3-compatible object store (backed by
|
|
18
|
-
* [
|
|
28
|
+
* [GoFakeS3](https://github.com/johannesboyne/gofakes3)), with an instrumented S3
|
|
19
29
|
* client on `ctx.svc.<key>.client`. Drop into `environment.services`:
|
|
20
30
|
*
|
|
21
31
|
* ```ts
|
|
@@ -51,7 +61,7 @@ export function s3(opts = {}) {
|
|
|
51
61
|
const secretAccessKey = opts.secretAccessKey ?? "s3";
|
|
52
62
|
const region = opts.region ?? "us-east-1";
|
|
53
63
|
return {
|
|
54
|
-
image: { type: "
|
|
64
|
+
image: { type: "dockerfile", content: S3_DOCKERFILE },
|
|
55
65
|
ports: [S3_PORT],
|
|
56
66
|
...(opts.env ? { env: opts.env } : {}),
|
|
57
67
|
...(opts.hosts
|
package/package.json
CHANGED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Linux opt-in: creates and removes its own containers/network/image.
|
|
2
|
+
// SPECTEST_TEST_S3_DOCKER=1 bun test src/components/s3-docker.test.ts
|
|
3
|
+
// Add SPECTEST_TEST_S3_BASELINE=1 to run the same contract against S3Mock 5.1.0.
|
|
4
|
+
import { expect, test } from "bun:test";
|
|
5
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { s3 } from "./s3.js";
|
|
9
|
+
import { runContainerArgs } from "../harness/container-run.js";
|
|
10
|
+
import { startRecording, stopRecording } from "../recorder.js";
|
|
11
|
+
|
|
12
|
+
async function run(args: string[]) {
|
|
13
|
+
const child = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
14
|
+
const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
|
|
15
|
+
return { exit, stdout, stderr };
|
|
16
|
+
}
|
|
17
|
+
async function checked(args: string[]) {
|
|
18
|
+
const result = await run(args);
|
|
19
|
+
if (result.exit) throw new Error(`${args.join(" ")}: ${result.stderr}\n${result.stdout}`);
|
|
20
|
+
return result.stdout.trim();
|
|
21
|
+
}
|
|
22
|
+
async function ready(endpoint: string) {
|
|
23
|
+
const start = performance.now();
|
|
24
|
+
while (performance.now() - start < 60_000) {
|
|
25
|
+
try { if ((await fetch(endpoint, { signal: AbortSignal.timeout(500) })).ok) return performance.now() - start; } catch {}
|
|
26
|
+
await Bun.sleep(20);
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`S3 not ready: ${endpoint}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const enabled = process.env.SPECTEST_TEST_S3_DOCKER === "1";
|
|
32
|
+
const baseline = process.env.SPECTEST_TEST_S3_BASELINE === "1";
|
|
33
|
+
test.skipIf(!enabled)("S3 preserves the public component and object-storage contract", async () => {
|
|
34
|
+
const root = mkdtempSync(join(tmpdir(), "spectest-s3-"));
|
|
35
|
+
const name = `spectest-s3-${process.pid}-${Date.now()}`;
|
|
36
|
+
const tag = baseline ? "adobe/s3mock:5.1.0" : name;
|
|
37
|
+
const definition = s3({ buckets: ["uploads", "assets"], hosts: ["files.test"], env: { CUSTOM_S3_ENV: "forwarded" } });
|
|
38
|
+
let proxy: ReturnType<typeof Bun.serve> | undefined;
|
|
39
|
+
try {
|
|
40
|
+
await checked(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", "-subj", "/CN=localhost", "-addext", "subjectAltName=DNS:localhost", "-keyout", `${root}/key.pem`, "-out", `${root}/ca.pem`]);
|
|
41
|
+
if (baseline) await checked(["docker", "pull", tag]);
|
|
42
|
+
else {
|
|
43
|
+
writeFileSync(`${root}/Dockerfile`, definition.image.content);
|
|
44
|
+
await checked(["docker", "build", "-t", tag, root]);
|
|
45
|
+
}
|
|
46
|
+
await checked(["docker", "network", "create", name]);
|
|
47
|
+
// Exercise the real harness command, including its CA mounts, on scratch.
|
|
48
|
+
await checked(["docker", ...runContainerArgs({ svc: { ...definition, name }, tag, network: name, hostCacheName: "spectest-host", caPath: `${root}/ca.pem`, caBundle: `${root}/ca.pem` })]);
|
|
49
|
+
const ip = await checked(["docker", "inspect", "--format", "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", name]);
|
|
50
|
+
const endpoint = `http://${ip}:9090`;
|
|
51
|
+
console.log(`${baseline ? "S3Mock" : "GoFakeS3"} ready after ${Math.round(await ready(endpoint))} ms of probing`);
|
|
52
|
+
const info = JSON.parse(await checked(["docker", "inspect", name]))[0];
|
|
53
|
+
expect(info.Config.Env).toContain("CUSTOM_S3_ENV=forwarded");
|
|
54
|
+
// No implicit bucket, even when the helper has a default bucket configured.
|
|
55
|
+
expect(await (await fetch(endpoint)).text()).not.toContain("<Bucket>");
|
|
56
|
+
expect((await fetch(`${endpoint}/missing/key`, { method: "PUT", body: "x" })).status).toBe(404);
|
|
57
|
+
await definition.setup!({ name: ip });
|
|
58
|
+
await definition.setup!({ name: ip }); // idempotent CreateBucket
|
|
59
|
+
|
|
60
|
+
const client = definition.helpers({ name: ip }).client;
|
|
61
|
+
startRecording();
|
|
62
|
+
await client.write("hello.txt", "hello world", { type: "text/plain" });
|
|
63
|
+
expect((await client.file("hello.txt").text()).unwrap()).toBe("hello world");
|
|
64
|
+
const events = stopRecording();
|
|
65
|
+
expect(events.filter(event => event.kind === "s3").map(event => event.op)).toEqual(["write", "read"]);
|
|
66
|
+
expect(events.some(event => event.error)).toBe(false);
|
|
67
|
+
expect((await client.stat("hello.txt")).unwrap().size).toBe(11);
|
|
68
|
+
const other = s3({ bucket: "assets", accessKeyId: "custom", secretAccessKey: "secret", region: "eu-west-1" }).helpers({ name: ip }).client;
|
|
69
|
+
await other.write("custom.txt", "custom credentials");
|
|
70
|
+
expect((await other.file("custom.txt").text()).unwrap()).toBe("custom credentials");
|
|
71
|
+
await client.write("override.txt", "per-call bucket", { bucket: "assets" });
|
|
72
|
+
expect((await other.file("override.txt").text()).unwrap()).toBe("per-call bucket");
|
|
73
|
+
|
|
74
|
+
// Host-preserving TLS termination: the store must remain path-style.
|
|
75
|
+
proxy = Bun.serve({ hostname: "127.0.0.1", port: 0, tls: { key: Bun.file(`${root}/key.pem`), cert: Bun.file(`${root}/ca.pem`) }, async fetch(request) {
|
|
76
|
+
const url = new URL(request.url);
|
|
77
|
+
return fetch(endpoint + url.pathname + url.search, { method: request.method, headers: request.headers, body: request.body, redirect: "manual" });
|
|
78
|
+
} });
|
|
79
|
+
for (const tls of [false, true]) {
|
|
80
|
+
const transport = (url: string) => tls ? url.replace(endpoint, `https://localhost:${proxy!.port}`) : url;
|
|
81
|
+
const options = { headers: { Host: "files.test", Origin: "https://app.test" }, ...(tls ? { tls: { ca: await Bun.file(`${root}/ca.pem`).text() } } : {}) };
|
|
82
|
+
const put = await fetch(transport(client.presign("presigned.txt", { method: "PUT" })), { ...options, method: "PUT", body: "presigned content" });
|
|
83
|
+
expect(put.status).toBe(200);
|
|
84
|
+
expect(put.headers.get("access-control-expose-headers")).toBe("*");
|
|
85
|
+
expect(await (await fetch(transport(client.presign("presigned.txt")), options)).text()).toBe("presigned content");
|
|
86
|
+
const preflight = await fetch(transport(`${endpoint}/uploads/presigned.txt`), { ...options, method: "OPTIONS", headers: { ...options.headers, "Access-Control-Request-Method": "PUT", "Access-Control-Request-Headers": "content-type,x-amz-meta-custom" } });
|
|
87
|
+
expect(preflight.status).toBe(200);
|
|
88
|
+
expect(preflight.headers.get("access-control-allow-origin")).toBe("https://app.test");
|
|
89
|
+
expect(preflight.headers.get("access-control-allow-headers")).toContain("x-amz-meta-custom");
|
|
90
|
+
expect(preflight.headers.get("access-control-allow-credentials")).toBe("true");
|
|
91
|
+
}
|
|
92
|
+
// Arbitrary signatures/dates must not turn into auth failures after restore.
|
|
93
|
+
const stale = await fetch(`${endpoint}/uploads/hello.txt`, { headers: { "x-amz-date": "20000101T000000Z", Authorization: "AWS4-HMAC-SHA256 invalid" } });
|
|
94
|
+
expect(stale.status).toBe(200);
|
|
95
|
+
const range = await fetch(`${endpoint}/uploads/hello.txt`, { headers: { Range: "bytes=1-4" } });
|
|
96
|
+
expect(range.status).toBe(206);
|
|
97
|
+
expect(await range.text()).toBe("ello");
|
|
98
|
+
const metadata = { "Content-Type": "text/plain", "Content-Disposition": "attachment; filename=hello.txt", "Cache-Control": "max-age=600", "Content-Language": "en", "Expires": "Wed, 28 Oct 2026 00:00:00 GMT", "x-amz-meta-custom": "retained" };
|
|
99
|
+
expect((await fetch(`${endpoint}/uploads/metadata.txt`, { method: "PUT", headers: metadata, body: "metadata" })).status).toBe(200);
|
|
100
|
+
const head = await fetch(`${endpoint}/uploads/metadata.txt`, { method: "HEAD" });
|
|
101
|
+
for (const [key, value] of Object.entries(metadata)) expect(head.headers.get(key)).toBe(value);
|
|
102
|
+
expect((await fetch(`${endpoint}/uploads/copied.txt`, { method: "PUT", headers: { "x-amz-copy-source": "/uploads/metadata.txt" } })).status).toBe(200);
|
|
103
|
+
expect((await client.file("copied.txt").text()).unwrap()).toBe("metadata");
|
|
104
|
+
|
|
105
|
+
// Exercise both Bun's multipart writer and explicit multi-part S3 requests.
|
|
106
|
+
const bytes = new Uint8Array(12 * 1024 * 1024).fill(65);
|
|
107
|
+
await client.write("large.bin", bytes);
|
|
108
|
+
expect((await client.file("large.bin").arrayBuffer()).unwrap()).toEqual(bytes.buffer);
|
|
109
|
+
const start = await fetch(`${endpoint}/uploads/manual.bin?uploads`, { method: "POST" });
|
|
110
|
+
expect(start.status).toBe(200);
|
|
111
|
+
const id = (await start.text()).match(/<UploadId>([^<]+)/)![1]!;
|
|
112
|
+
const etags: string[] = [];
|
|
113
|
+
for (const [i, size] of [[1, 5 * 1024 * 1024], [2, 1234]]) {
|
|
114
|
+
const part = await fetch(`${endpoint}/uploads/manual.bin?partNumber=${i}&uploadId=${encodeURIComponent(id)}`, { method: "PUT", body: new Uint8Array(size).fill(i) });
|
|
115
|
+
expect(part.status).toBe(200);
|
|
116
|
+
etags.push(part.headers.get("etag")!);
|
|
117
|
+
}
|
|
118
|
+
const complete = await fetch(`${endpoint}/uploads/manual.bin?uploadId=${encodeURIComponent(id)}`, { method: "POST", body: `<CompleteMultipartUpload>${etags.map((etag, i) => `<Part><PartNumber>${i + 1}</PartNumber><ETag>${etag}</ETag></Part>`).join("")}</CompleteMultipartUpload>` });
|
|
119
|
+
expect(complete.status).toBe(200);
|
|
120
|
+
expect((await client.stat("manual.bin")).unwrap().size).toBe(5 * 1024 * 1024 + 1234);
|
|
121
|
+
await client.write("pages/one", "one");
|
|
122
|
+
await client.write("pages/two", "two");
|
|
123
|
+
const page = (await client.list({ prefix: "pages/", maxKeys: 1 })).unwrap();
|
|
124
|
+
expect(page.contents?.map(item => item.key)).toEqual(["pages/one"]);
|
|
125
|
+
expect(page.isTruncated).toBe(true);
|
|
126
|
+
const next = (await client.list({ prefix: "pages/", maxKeys: 1, continuationToken: page.nextContinuationToken })).unwrap();
|
|
127
|
+
expect(next.contents?.map(item => item.key)).toEqual(["pages/two"]);
|
|
128
|
+
await client.delete("pages/one");
|
|
129
|
+
expect((await client.exists("pages/one")).unwrap()).toBe(false);
|
|
130
|
+
|
|
131
|
+
// Versioning stays opt-in and old versions remain readable.
|
|
132
|
+
expect((await fetch(`${endpoint}/assets?versioning`, { method: "PUT", body: '<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Enabled</Status></VersioningConfiguration>' })).status).toBe(200);
|
|
133
|
+
const first = await fetch(`${endpoint}/assets/versioned.txt`, { method: "PUT", body: "first" });
|
|
134
|
+
const version = first.headers.get("x-amz-version-id");
|
|
135
|
+
expect(version).toBeTruthy();
|
|
136
|
+
expect((await fetch(`${endpoint}/assets/versioned.txt`, { method: "PUT", body: "second" })).status).toBe(200);
|
|
137
|
+
expect(await (await fetch(`${endpoint}/assets/versioned.txt?versionId=${encodeURIComponent(version!)}`)).text()).toBe("first");
|
|
138
|
+
expect((await other.file("versioned.txt").text()).unwrap()).toBe("second");
|
|
139
|
+
|
|
140
|
+
await checked(["docker", "restart", name]);
|
|
141
|
+
await ready(endpoint);
|
|
142
|
+
// A process restart is different from a VM restore: the original S3Mock
|
|
143
|
+
// chose a fresh temporary store on every process start.
|
|
144
|
+
expect(await (await fetch(endpoint)).text()).not.toContain("<Bucket>");
|
|
145
|
+
await definition.setup!({ name: ip });
|
|
146
|
+
await client.write("after-restart.txt", "fresh store");
|
|
147
|
+
expect((await client.file("after-restart.txt").text()).unwrap()).toBe("fresh store");
|
|
148
|
+
} catch (error) {
|
|
149
|
+
console.error(await run(["docker", "logs", "--tail", "40", name]));
|
|
150
|
+
throw error;
|
|
151
|
+
} finally {
|
|
152
|
+
stopRecording();
|
|
153
|
+
proxy?.stop(true);
|
|
154
|
+
await run(["docker", "rm", "-f", "-v", name]);
|
|
155
|
+
await run(["docker", "network", "rm", name]);
|
|
156
|
+
if (!baseline) await run(["docker", "image", "rm", tag]);
|
|
157
|
+
rmSync(root, { recursive: true, force: true });
|
|
158
|
+
}
|
|
159
|
+
}, 600_000);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Native S3 component runtime
|
|
2
|
+
|
|
3
|
+
GoFakeS3 is pinned by `go.mod`/`go.sum`. `s3.ts` embeds these assets into a constant
|
|
4
|
+
multi-stage Dockerfile, builds for the worker's native architecture, and ships
|
|
5
|
+
only the static binary and dependency licenses. The Go builder is pinned to a
|
|
6
|
+
multi-architecture digest. No project files or options enter the image build.
|
|
7
|
+
|
|
8
|
+
The entrypoint retains the component's port 9090, path-style addressing, no
|
|
9
|
+
automatic buckets, no signature/date validation, and permissive browser CORS.
|
|
10
|
+
The memory backend supports pagination and versioning. Full VM snapshots clone
|
|
11
|
+
its buckets, versions and pending multipart uploads; restarting the process
|
|
12
|
+
starts empty, matching S3Mock's default temporary store.
|
|
13
|
+
|
|
14
|
+
`metadata.patch` preserves Cache-Control, Content-Language and Expires on object
|
|
15
|
+
uploads, multipart initiation and copies. Upstream currently drops these headers
|
|
16
|
+
in `metadataHeaders`. The patch applies to the vendored, checksum-verified module;
|
|
17
|
+
a failed patch stops the build. Recheck it when changing the upstream pin.
|
|
18
|
+
|
|
19
|
+
From `sdk`, run the real-image contract on a Linux Docker host:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
SPECTEST_TEST_S3_DOCKER=1 bun test src/components/s3-docker.test.ts
|
|
23
|
+
SPECTEST_TEST_S3_DOCKER=1 SPECTEST_TEST_S3_BASELINE=1 bun test src/components/s3-docker.test.ts
|
|
24
|
+
bun test src/components/s3.test.ts src/components/internal-fetch.test.ts
|
|
25
|
+
bun run build
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The baseline uses S3Mock 5.1.0. The contract covers helpers and recording, setup,
|
|
29
|
+
credentials, HTTPS with preserved Host, browser CORS, unsigned/stale signatures,
|
|
30
|
+
metadata, copy/range requests, multipart, paginated listing, versioning, and
|
|
31
|
+
fresh-process lifecycle. It does not prove every S3 API is interchangeable:
|
|
32
|
+
GoFakeS3 lacks some S3Mock features, including tagging, lifecycle and retention.
|
|
33
|
+
Java/Spring/S3Mock-specific environment settings are no longer interpreted.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module spectest-s3
|
|
2
|
+
|
|
3
|
+
go 1.24
|
|
4
|
+
|
|
5
|
+
require github.com/johannesboyne/gofakes3 v1.2.1-0.20261011201219-0016ef2bf153
|
|
6
|
+
|
|
7
|
+
require (
|
|
8
|
+
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
|
|
9
|
+
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
|
|
10
|
+
golang.org/x/tools v0.8.0 // indirect
|
|
11
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
|
2
|
+
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
|
3
|
+
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
|
4
|
+
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
|
5
|
+
github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM=
|
|
6
|
+
github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ=
|
|
7
|
+
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.75 h1:S61/E3N01oral6B3y9hZ2E1iFDqCZPPOBoBQretCnBI=
|
|
8
|
+
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.75/go.mod h1:bDMQbkI1vJbNjnvJYpPTSNYBkI/VIv18ngWb/K84tkk=
|
|
9
|
+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
|
10
|
+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
|
11
|
+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
|
12
|
+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
|
13
|
+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
|
|
14
|
+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
|
|
15
|
+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
|
16
|
+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
|
17
|
+
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
|
|
18
|
+
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
|
|
19
|
+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
|
|
20
|
+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
|
21
|
+
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
|
|
22
|
+
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
|
|
23
|
+
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
|
|
24
|
+
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
|
|
25
|
+
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
|
26
|
+
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
|
27
|
+
github.com/cevatbarisyilmaz/ara v0.0.4 h1:SGH10hXpBJhhTlObuZzTuFn1rrdmjQImITXnZVPSodc=
|
|
28
|
+
github.com/cevatbarisyilmaz/ara v0.0.4/go.mod h1:BfFOxnUd6Mj6xmcvRxHN3Sr21Z1T3U2MYkYOmoQe4Ts=
|
|
29
|
+
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
|
30
|
+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
31
|
+
github.com/johannesboyne/gofakes3 v1.2.1-0.20261011201219-0016ef2bf153 h1:ZaLBZJfUnKBlrPu0AX0T/i1N9BNU54j8sl9mCLzmpI0=
|
|
32
|
+
github.com/johannesboyne/gofakes3 v1.2.1-0.20261011201219-0016ef2bf153/go.mod h1:UHhRZRod9rENGFrUWTYnQHZqlNgSmjOq8DaD/ATQYRM=
|
|
33
|
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
34
|
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
35
|
+
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
|
|
36
|
+
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
|
|
37
|
+
github.com/spf13/afero v1.2.1 h1:qgMbHoJbPbw579P+1zVY+6n4nIFuIchaIjzZ/I/Yq8M=
|
|
38
|
+
github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
|
|
39
|
+
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
|
|
40
|
+
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
|
41
|
+
go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
|
|
42
|
+
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
|
43
|
+
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d h1:Ns9kd1Rwzw7t0BR8XMphenji4SmIoNZPn8zhYmaVKP8=
|
|
44
|
+
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d/go.mod h1:92Uoe3l++MlthCm+koNi0tcUCX3anayogF0Pa/sp24k=
|
|
45
|
+
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
|
|
46
|
+
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
47
|
+
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
|
48
|
+
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
|
49
|
+
golang.org/x/tools v0.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y=
|
|
50
|
+
golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4=
|
|
51
|
+
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU=
|
|
52
|
+
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
|
|
53
|
+
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
|
54
|
+
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The component's native entrypoint. Keep its defaults compatible with S3Mock;
|
|
2
|
+
// the upstream CLI enforces clock skew and has different browser CORS defaults.
|
|
3
|
+
package main
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"log"
|
|
7
|
+
"net/http"
|
|
8
|
+
|
|
9
|
+
"github.com/johannesboyne/gofakes3"
|
|
10
|
+
"github.com/johannesboyne/gofakes3/backend/s3mem"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func main() {
|
|
14
|
+
// VM snapshots include RAM: buckets, versions and in-flight multipart state
|
|
15
|
+
// fork together. A fresh process starts empty, as S3Mock did by default.
|
|
16
|
+
// Unlike the Bolt backend, s3mem also supports bucket versioning.
|
|
17
|
+
backend := s3mem.New()
|
|
18
|
+
server := gofakes3.New(backend,
|
|
19
|
+
gofakes3.WithTimeSkewLimit(0), // S3Mock accepts arbitrary/expired signatures.
|
|
20
|
+
gofakes3.WithHostBucket(false),
|
|
21
|
+
gofakes3.WithAutoBucket(false),
|
|
22
|
+
gofakes3.WithLogger(gofakes3.GlobalLog()),
|
|
23
|
+
).Server()
|
|
24
|
+
log.Fatal(http.ListenAndServe(":9090", cors(server)))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Preserve S3Mock's permissive browser uploads, including arbitrary metadata
|
|
28
|
+
// headers. GoFakeS3's default allow-list rejects custom x-amz-meta-* headers and
|
|
29
|
+
// only exposes ETag on preflights, hiding it from actual browser responses.
|
|
30
|
+
func cors(next http.Handler) http.Handler {
|
|
31
|
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
32
|
+
origin := r.Header.Get("Origin")
|
|
33
|
+
w.Header().Add("Vary", "Origin")
|
|
34
|
+
w.Header().Add("Vary", "Access-Control-Request-Method")
|
|
35
|
+
w.Header().Add("Vary", "Access-Control-Request-Headers")
|
|
36
|
+
if origin != "" && r.Method == "OPTIONS" && r.Header.Get("Access-Control-Request-Method") != "" {
|
|
37
|
+
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
38
|
+
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
39
|
+
w.Header().Set("Access-Control-Allow-Methods", r.Header.Get("Access-Control-Request-Method"))
|
|
40
|
+
w.Header().Set("Access-Control-Allow-Headers", r.Header.Get("Access-Control-Request-Headers"))
|
|
41
|
+
w.WriteHeader(http.StatusOK)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
writer := &corsWriter{ResponseWriter: w, hasOrigin: origin != ""}
|
|
45
|
+
next.ServeHTTP(writer, r)
|
|
46
|
+
if !writer.written {
|
|
47
|
+
writer.WriteHeader(http.StatusOK)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type corsWriter struct {
|
|
53
|
+
http.ResponseWriter
|
|
54
|
+
hasOrigin bool
|
|
55
|
+
written bool
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func (w *corsWriter) WriteHeader(status int) {
|
|
59
|
+
if w.written {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
w.written = true
|
|
63
|
+
if w.hasOrigin {
|
|
64
|
+
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
65
|
+
w.Header().Set("Access-Control-Expose-Headers", "*")
|
|
66
|
+
} else {
|
|
67
|
+
w.Header().Del("Access-Control-Allow-Origin")
|
|
68
|
+
}
|
|
69
|
+
w.ResponseWriter.WriteHeader(status)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func (w *corsWriter) Write(body []byte) (int, error) {
|
|
73
|
+
if !w.written {
|
|
74
|
+
w.WriteHeader(http.StatusOK)
|
|
75
|
+
}
|
|
76
|
+
return w.ResponseWriter.Write(body)
|
|
77
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
--- a/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
|
|
2
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
|
|
3
|
+
@@ -1191,6 +1191,9 @@
|
|
4
|
+
for hk, hv := range headers {
|
|
5
|
+
if strings.HasPrefix(hk, "X-Amz-") ||
|
|
6
|
+
hk == "Content-Type" ||
|
|
7
|
+
+ hk == "Cache-Control" ||
|
|
8
|
+
+ hk == "Content-Language" ||
|
|
9
|
+
+ hk == "Expires" ||
|
|
10
|
+
hk == "Content-Disposition" ||
|
|
11
|
+
hk == "Content-Encoding" {
|
|
12
|
+
meta[hk] = hv[0]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { s3 } from "./s3.js";
|
|
3
|
+
|
|
4
|
+
function presign(options: Parameters<typeof s3>[0], bucket?: string) {
|
|
5
|
+
return new URL(s3(options).helpers({ name: "storage" }).client.presign("hello.txt", bucket ? { bucket } : {}));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
test("S3 keeps its endpoint, readiness, credential and region defaults", () => {
|
|
9
|
+
const service = s3();
|
|
10
|
+
expect(service.ports).toEqual([9090]);
|
|
11
|
+
expect(service.readyCheck).toEqual({ type: "http", port: 9090, path: "/", timeoutSecs: 60 });
|
|
12
|
+
expect(service.setup).toBeUndefined();
|
|
13
|
+
expect(service.env).toBeUndefined();
|
|
14
|
+
expect(service.tls).toBeUndefined();
|
|
15
|
+
const url = presign({}, "uploads");
|
|
16
|
+
expect(url.origin).toBe("http://storage:9090");
|
|
17
|
+
expect(url.pathname).toBe("/uploads/hello.txt");
|
|
18
|
+
expect(url.searchParams.get("X-Amz-Credential")).toMatch(/^s3\/\d+\/us-east-1\/s3\/aws4_request$/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("S3 keeps bucket precedence, caller credentials, region, env and TLS routes", () => {
|
|
22
|
+
const options = { buckets: ["uploads", "assets"], bucket: "assets", accessKeyId: "custom", secretAccessKey: "secret", region: "eu-west-1", hosts: ["files.test", "storage.test"], env: { CUSTOM: "value" } };
|
|
23
|
+
expect(presign({ buckets: options.buckets }).pathname).toBe("/uploads/hello.txt");
|
|
24
|
+
const url = presign(options);
|
|
25
|
+
expect(url.pathname).toBe("/assets/hello.txt");
|
|
26
|
+
expect(url.searchParams.get("X-Amz-Credential")).toMatch(/^custom\/\d+\/eu-west-1\/s3\/aws4_request$/);
|
|
27
|
+
expect(presign(options, "override").pathname).toBe("/override/hello.txt");
|
|
28
|
+
expect(presign({ ...options, secretAccessKey: "different" }).searchParams.get("X-Amz-Signature")).not.toBe(url.searchParams.get("X-Amz-Signature"));
|
|
29
|
+
expect(s3(options).env).toEqual(options.env);
|
|
30
|
+
expect(s3(options).tls).toEqual(options.hosts.map(hostname => ({ hostname, port: 9090 })));
|
|
31
|
+
// Selecting a helper default has never implicitly created that bucket.
|
|
32
|
+
expect(s3({ bucket: "not-created" }).setup).toBeUndefined();
|
|
33
|
+
expect(s3({ buckets: [] }).setup).toBeUndefined();
|
|
34
|
+
// Options must not create per-project copies of the same native image.
|
|
35
|
+
expect(s3(options).image).toEqual(s3().image);
|
|
36
|
+
});
|
package/src/components/s3.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import { rawFetch } from "../harness/raw-fetch.js";
|
|
2
3
|
import type { ServiceDefinition } from "../index.js";
|
|
3
4
|
import { S3Client, type S3ClientLike } from "../s3.js";
|
|
4
5
|
|
|
5
6
|
export interface S3Options {
|
|
6
7
|
/**
|
|
7
|
-
* Buckets to create on first boot. The
|
|
8
|
-
* env is unreliable across versions, so the component creates them in `setup`
|
|
8
|
+
* Buckets to create on first boot. The component creates them in `setup`
|
|
9
9
|
* (a plain CreateBucket `PUT`) — captured into the warm snapshot, never
|
|
10
10
|
* re-run on warm starts/forks.
|
|
11
11
|
*/
|
|
@@ -45,24 +45,33 @@ export interface S3Helpers {
|
|
|
45
45
|
client: S3ClientLike;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
//
|
|
49
|
-
// is
|
|
50
|
-
//
|
|
51
|
-
const
|
|
48
|
+
// Build once per architecture/cache from pinned source: upstream's Docker Hub
|
|
49
|
+
// image is amd64-only. Only the static native server ships in the runtime image.
|
|
50
|
+
// Keep the Dockerfile independent of options so stores share the build cache.
|
|
51
|
+
const S3_DOCKERFILE = `
|
|
52
|
+
FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS build
|
|
53
|
+
RUN apk add --no-cache patch
|
|
54
|
+
WORKDIR /src
|
|
55
|
+
${["go.mod", "go.sum", "main.go", "metadata.patch"].map((file) => {
|
|
56
|
+
const data = readFileSync(new URL(`./s3-runtime/${file}`, import.meta.url)).toString("base64");
|
|
57
|
+
return `RUN printf '%s' '${data}' | base64 -d > ${file}`;
|
|
58
|
+
}).join("\n")}
|
|
59
|
+
RUN go mod vendor && patch -p1 < metadata.patch
|
|
60
|
+
RUN CGO_ENABLED=0 go build -mod=vendor -trimpath -ldflags='-s -w' -o /s3-server .
|
|
61
|
+
FROM scratch
|
|
62
|
+
COPY --from=build /s3-server /s3-server
|
|
63
|
+
COPY --from=build /src/vendor/github.com/johannesboyne/gofakes3/LICENSE /licenses/gofakes3
|
|
64
|
+
COPY --from=build /src/vendor/github.com/ryszard/goskiplist/LICENSE /licenses/goskiplist
|
|
65
|
+
EXPOSE 9090
|
|
66
|
+
ENTRYPOINT ["/s3-server"]
|
|
67
|
+
`;
|
|
52
68
|
|
|
53
|
-
//
|
|
54
|
-
// option. The backing server's HTTP port is baked into the image, and nothing
|
|
55
|
-
// the component returns can move it — so a `port` option could only relabel
|
|
56
|
-
// the ready check, the declared ports and the client endpoint while the
|
|
57
|
-
// container kept listening here. That is exactly what it did until 0.78.0: a
|
|
58
|
-
// project that passed one got 60 s of refused probes and an environment that
|
|
59
|
-
// never booted, with a healthy container beside it. A service reaches the
|
|
60
|
-
// store at `http://<key>:9090`.
|
|
69
|
+
// Public endpoint stays fixed across backing-server changes.
|
|
61
70
|
const S3_PORT = 9090;
|
|
62
71
|
|
|
63
72
|
/**
|
|
64
73
|
* A ready-to-use, S3-compatible object store (backed by
|
|
65
|
-
* [
|
|
74
|
+
* [GoFakeS3](https://github.com/johannesboyne/gofakes3)), with an instrumented S3
|
|
66
75
|
* client on `ctx.svc.<key>.client`. Drop into `environment.services`:
|
|
67
76
|
*
|
|
68
77
|
* ```ts
|
|
@@ -99,7 +108,7 @@ export function s3(opts: S3Options = {}) {
|
|
|
99
108
|
const region = opts.region ?? "us-east-1";
|
|
100
109
|
|
|
101
110
|
return {
|
|
102
|
-
image: { type: "
|
|
111
|
+
image: { type: "dockerfile", content: S3_DOCKERFILE },
|
|
103
112
|
ports: [S3_PORT],
|
|
104
113
|
...(opts.env ? { env: opts.env } : {}),
|
|
105
114
|
...(opts.hosts
|