@specific.dev/spectest 0.129.0 → 0.131.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/README.md +5 -2
- 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/dist/harness/phone.py +9 -7
- package/dist/phone.js +7 -7
- 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
- package/src/harness/phone.py +9 -7
- package/src/phone.test.ts +11 -12
- package/src/phone.ts +8 -8
package/README.md
CHANGED
|
@@ -478,8 +478,11 @@ time, and ends when that step completes. Both `call(destination, { waitFor })`
|
|
|
478
478
|
and `say(text, { waitFor })` poll application state, then wait for incoming
|
|
479
479
|
silence. For `call`, polling starts after the call connects, allowing the greeting
|
|
480
480
|
to finish within the call step. For `say`, polling starts after outgoing playback.
|
|
481
|
-
Without `waitFor`, `call` ends when connected and `say` ends after outgoing playback. The
|
|
482
|
-
|
|
481
|
+
Without `waitFor`, `call` ends when connected and `say` ends after outgoing playback. The
|
|
482
|
+
wait has no timeout of its own: the test case's `timeoutMs` is the only ceiling,
|
|
483
|
+
and a bound on the reply belongs inside the predicate (for example a `ctx.poll`
|
|
484
|
+
with its own `timeoutMs`), where it appears in the timeline. The quiet
|
|
485
|
+
period (700 ms) and poll interval (100 ms) are
|
|
483
486
|
internal defaults for now. `waitFor` may return a nonempty transcript string
|
|
484
487
|
instead of `true`; after the silence wait, it appears as **Response** below **Caller** in an indented chat row beneath the waveform. Return `false` or `""` to keep waiting. Your test supplies the text
|
|
485
488
|
from application state or a fake; an async callback can wait and then return it.
|
|
@@ -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/dist/harness/phone.py
CHANGED
|
@@ -271,12 +271,14 @@ def main():
|
|
|
271
271
|
else:
|
|
272
272
|
time.sleep(0.01)
|
|
273
273
|
|
|
274
|
-
def wait(predicate, timeout, live=False):
|
|
275
|
-
|
|
274
|
+
def wait(predicate, timeout=None, live=False):
|
|
275
|
+
# No timeout: run until satisfied, the call ends, or the SDK closes
|
|
276
|
+
# stdin (which happens at case timeout and cleanup).
|
|
277
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
276
278
|
while not predicate():
|
|
277
279
|
if live and call.disconnected:
|
|
278
280
|
raise ValueError(f"SIP call ended ({call.status})")
|
|
279
|
-
if time.monotonic() >= deadline:
|
|
281
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
280
282
|
raise ValueError("SIP operation timed out")
|
|
281
283
|
pump()
|
|
282
284
|
|
|
@@ -374,14 +376,14 @@ def main():
|
|
|
374
376
|
if not call or call.disconnected or call.media is None:
|
|
375
377
|
raise ValueError("No connected call; cannot wait for silence")
|
|
376
378
|
silence = float(data["silenceMs"]) / 1000
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
raise ValueError("Silence duration and timeout must be positive")
|
|
379
|
+
if not math.isfinite(silence) or silence <= 0:
|
|
380
|
+
raise ValueError("Silence duration must be positive")
|
|
380
381
|
# A fresh quiet window starts AFTER the application predicate
|
|
381
382
|
# succeeds; quiet heard before that does not satisfy this wait.
|
|
383
|
+
# The wait is bounded only by the test case's own timeout.
|
|
382
384
|
began = time.monotonic()
|
|
383
385
|
wait(lambda: time.monotonic() - max(began, call.incoming.last_voice) >= silence,
|
|
384
|
-
|
|
386
|
+
live=True)
|
|
385
387
|
result["offset"] = call.recorder.offset
|
|
386
388
|
elif action == "position":
|
|
387
389
|
if not call:
|
package/dist/phone.js
CHANGED
|
@@ -116,7 +116,9 @@ class PhoneSession {
|
|
|
116
116
|
return Promise.reject(new Error("Phone is closed"));
|
|
117
117
|
const id = ++this.nextId;
|
|
118
118
|
return new Promise((resolve, reject) => {
|
|
119
|
-
const timer =
|
|
119
|
+
const timer = Number.isFinite(timeout)
|
|
120
|
+
? setTimeout(() => { this.fail(new Error(`Phone ${action} timed out after ${timeout}ms`)); void this.close(); }, timeout)
|
|
121
|
+
: undefined;
|
|
120
122
|
this.pending.set(id, { resolve, reject, timer, position });
|
|
121
123
|
this.child.stdin.write(JSON.stringify({ id, action, ...data }) + "\n");
|
|
122
124
|
});
|
|
@@ -128,15 +130,15 @@ class PhoneSession {
|
|
|
128
130
|
await this.request("open", { ...this.options, directory: this.directory }, 60_000);
|
|
129
131
|
return this;
|
|
130
132
|
}
|
|
133
|
+
// The predicate and the silence wait have no timeout of their own: a bound on
|
|
134
|
+
// the reply belongs inside the predicate (a poll with its own timeout), and the
|
|
135
|
+
// case timeout closes the phone, which aborts this wait.
|
|
131
136
|
async waitForResponse(options) {
|
|
132
|
-
const timeout = 30_000;
|
|
133
|
-
const deadline = performance.now() + timeout;
|
|
134
137
|
const controller = new AbortController();
|
|
135
138
|
const cancel = () => controller.abort(this.failure ?? new Error("Phone closed"));
|
|
136
139
|
this.cancellation.signal.addEventListener("abort", cancel, { once: true });
|
|
137
140
|
if (this.closed)
|
|
138
141
|
cancel();
|
|
139
|
-
const timer = setTimeout(() => controller.abort(new Error(`Phone response wait timed out after ${timeout}ms`)), timeout);
|
|
140
142
|
const aborted = new Promise((_, reject) => {
|
|
141
143
|
const rejectAbort = () => reject(controller.signal.reason);
|
|
142
144
|
controller.signal.addEventListener("abort", rejectAbort, { once: true });
|
|
@@ -163,13 +165,11 @@ class PhoneSession {
|
|
|
163
165
|
}
|
|
164
166
|
}
|
|
165
167
|
controller.signal.throwIfAborted();
|
|
166
|
-
const
|
|
167
|
-
const reply = await this.request("wait_silence", { silenceMs: 700, timeoutMs: remaining }, remaining + 1000);
|
|
168
|
+
const reply = await this.request("wait_silence", { silenceMs: 700 }, Infinity);
|
|
168
169
|
return { ...reply, response };
|
|
169
170
|
})]);
|
|
170
171
|
}
|
|
171
172
|
finally {
|
|
172
|
-
clearTimeout(timer);
|
|
173
173
|
this.cancellation.signal.removeEventListener("abort", cancel);
|
|
174
174
|
controller.abort();
|
|
175
175
|
branch.finish(false);
|
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
|
package/src/harness/phone.py
CHANGED
|
@@ -271,12 +271,14 @@ def main():
|
|
|
271
271
|
else:
|
|
272
272
|
time.sleep(0.01)
|
|
273
273
|
|
|
274
|
-
def wait(predicate, timeout, live=False):
|
|
275
|
-
|
|
274
|
+
def wait(predicate, timeout=None, live=False):
|
|
275
|
+
# No timeout: run until satisfied, the call ends, or the SDK closes
|
|
276
|
+
# stdin (which happens at case timeout and cleanup).
|
|
277
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
276
278
|
while not predicate():
|
|
277
279
|
if live and call.disconnected:
|
|
278
280
|
raise ValueError(f"SIP call ended ({call.status})")
|
|
279
|
-
if time.monotonic() >= deadline:
|
|
281
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
280
282
|
raise ValueError("SIP operation timed out")
|
|
281
283
|
pump()
|
|
282
284
|
|
|
@@ -374,14 +376,14 @@ def main():
|
|
|
374
376
|
if not call or call.disconnected or call.media is None:
|
|
375
377
|
raise ValueError("No connected call; cannot wait for silence")
|
|
376
378
|
silence = float(data["silenceMs"]) / 1000
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
raise ValueError("Silence duration and timeout must be positive")
|
|
379
|
+
if not math.isfinite(silence) or silence <= 0:
|
|
380
|
+
raise ValueError("Silence duration must be positive")
|
|
380
381
|
# A fresh quiet window starts AFTER the application predicate
|
|
381
382
|
# succeeds; quiet heard before that does not satisfy this wait.
|
|
383
|
+
# The wait is bounded only by the test case's own timeout.
|
|
382
384
|
began = time.monotonic()
|
|
383
385
|
wait(lambda: time.monotonic() - max(began, call.incoming.last_voice) >= silence,
|
|
384
|
-
|
|
386
|
+
live=True)
|
|
385
387
|
result["offset"] = call.recorder.offset
|
|
386
388
|
elif action == "position":
|
|
387
389
|
if not call:
|
package/src/phone.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterAll, afterEach, beforeAll, expect,
|
|
1
|
+
import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
|
|
2
2
|
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
@@ -55,10 +55,9 @@ createInterface({ input: process.stdin }).on("line", async line => {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
if (data.action === "wait_silence") {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
: {offset:offset()};
|
|
58
|
+
if (data.timeoutMs !== undefined) throw new Error("wait_silence must not carry a timeout");
|
|
59
|
+
await Bun.sleep(data.silenceMs);
|
|
60
|
+
result = {offset:offset()};
|
|
62
61
|
}
|
|
63
62
|
if (data.action === "position") result = {offset:offset()};
|
|
64
63
|
console.log(JSON.stringify({ id: data.id, ...result }));
|
|
@@ -363,17 +362,17 @@ test("waits for the predicate then a fresh silence window inside one serialized
|
|
|
363
362
|
expect(recording!.duration - b.end).toBeGreaterThanOrEqual(.04);
|
|
364
363
|
});
|
|
365
364
|
|
|
366
|
-
test("predicate errors
|
|
365
|
+
test("predicate errors fail the say step without waiting for silence, and a hung predicate waits until the phone closes", async () => {
|
|
367
366
|
startRecording();
|
|
368
367
|
const phone = await scope.open(options);
|
|
369
368
|
await phone.call("123");
|
|
370
369
|
await expect(phone.say("Throw", { waitFor: () => { throw new Error("Application failed"); } })).rejects.toThrow("Application failed");
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
370
|
+
// No response-wait timeout of its own: only closing the phone (case timeout
|
|
371
|
+
// or cleanup) ends a predicate that never returns.
|
|
372
|
+
const hung = phone.say("Hang", { waitFor: () => new Promise(() => {}) }).catch(error => error);
|
|
373
|
+
await Bun.sleep(200);
|
|
374
|
+
await phone.close();
|
|
375
|
+
expect(String(await hung)).toContain("closed");
|
|
377
376
|
await scope.finish();
|
|
378
377
|
const events = stopRecording() as any[];
|
|
379
378
|
expect(events.slice(1).map(e => e.status)).toEqual(["failed", "failed"]);
|
package/src/phone.ts
CHANGED
|
@@ -83,7 +83,7 @@ export async function releasePhoneRecording(id: string) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
class PhoneSession implements Phone {
|
|
86
|
-
private pending = new Map<number, { resolve: (r: Reply) => void; reject: (e: Error) => void; timer
|
|
86
|
+
private pending = new Map<number, { resolve: (r: Reply) => void; reject: (e: Error) => void; timer?: ReturnType<typeof setTimeout>; position?: (event: string, offset: number) => void }>();
|
|
87
87
|
private nextId = 0;
|
|
88
88
|
private closed = false;
|
|
89
89
|
private failure?: Error;
|
|
@@ -138,7 +138,9 @@ class PhoneSession implements Phone {
|
|
|
138
138
|
if (this.closed) return Promise.reject(new Error("Phone is closed"));
|
|
139
139
|
const id = ++this.nextId;
|
|
140
140
|
return new Promise((resolve, reject) => {
|
|
141
|
-
const timer =
|
|
141
|
+
const timer = Number.isFinite(timeout)
|
|
142
|
+
? setTimeout(() => { this.fail(new Error(`Phone ${action} timed out after ${timeout}ms`)); void this.close(); }, timeout)
|
|
143
|
+
: undefined;
|
|
142
144
|
this.pending.set(id, { resolve, reject, timer, position });
|
|
143
145
|
this.child.stdin.write(JSON.stringify({ id, action, ...data }) + "\n");
|
|
144
146
|
});
|
|
@@ -149,14 +151,14 @@ class PhoneSession implements Phone {
|
|
|
149
151
|
await this.request("open", { ...this.options, directory: this.directory }, 60_000);
|
|
150
152
|
return this;
|
|
151
153
|
}
|
|
154
|
+
// The predicate and the silence wait have no timeout of their own: a bound on
|
|
155
|
+
// the reply belongs inside the predicate (a poll with its own timeout), and the
|
|
156
|
+
// case timeout closes the phone, which aborts this wait.
|
|
152
157
|
private async waitForResponse(options: CallOptions): Promise<Reply & { response?: string }> {
|
|
153
|
-
const timeout = 30_000;
|
|
154
|
-
const deadline = performance.now() + timeout;
|
|
155
158
|
const controller = new AbortController();
|
|
156
159
|
const cancel = () => controller.abort(this.failure ?? new Error("Phone closed"));
|
|
157
160
|
this.cancellation.signal.addEventListener("abort", cancel, { once: true });
|
|
158
161
|
if (this.closed) cancel();
|
|
159
|
-
const timer = setTimeout(() => controller.abort(new Error(`Phone response wait timed out after ${timeout}ms`)), timeout);
|
|
160
162
|
const aborted = new Promise<never>((_, reject) => {
|
|
161
163
|
const rejectAbort = () => reject(controller.signal.reason);
|
|
162
164
|
controller.signal.addEventListener("abort", rejectAbort, { once: true });
|
|
@@ -181,12 +183,10 @@ class PhoneSession implements Phone {
|
|
|
181
183
|
}
|
|
182
184
|
}
|
|
183
185
|
controller.signal.throwIfAborted();
|
|
184
|
-
const
|
|
185
|
-
const reply = await this.request("wait_silence", { silenceMs: 700, timeoutMs: remaining }, remaining + 1000);
|
|
186
|
+
const reply = await this.request("wait_silence", { silenceMs: 700 }, Infinity);
|
|
186
187
|
return { ...reply, response };
|
|
187
188
|
})]);
|
|
188
189
|
} finally {
|
|
189
|
-
clearTimeout(timer);
|
|
190
190
|
this.cancellation.signal.removeEventListener("abort", cancel);
|
|
191
191
|
controller.abort();
|
|
192
192
|
branch.finish(false);
|