@specific.dev/spectest 0.131.5 → 0.131.7
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 +13 -0
- package/dist/components/s3-runtime/main.go +5 -1
- package/dist/components/s3-runtime/main_test.go +199 -0
- package/dist/components/s3-runtime/multipart.patch +241 -0
- package/dist/components/s3-runtime/test.sh +12 -0
- package/dist/components/s3.js +2 -2
- package/dist/harness/sticky-consumer.d.ts +7 -2
- package/dist/harness/sticky-consumer.js +23 -18
- package/package.json +1 -1
- package/src/components/s3-runtime/README.md +13 -0
- package/src/components/s3-runtime/main.go +5 -1
- package/src/components/s3-runtime/main_test.go +199 -0
- package/src/components/s3-runtime/multipart.patch +241 -0
- package/src/components/s3-runtime/test.sh +12 -0
- package/src/components/s3.ts +2 -2
- package/src/harness/sticky-consumer.test.ts +31 -4
- package/src/harness/sticky-consumer.ts +25 -18
|
@@ -16,6 +16,19 @@ uploads, multipart initiation and copies. Upstream currently drops these headers
|
|
|
16
16
|
in `metadataHeaders`. The patch applies to the vendored, checksum-verified module;
|
|
17
17
|
a failed patch stops the build. Recheck it when changing the upstream pin.
|
|
18
18
|
|
|
19
|
+
`multipart.patch` adds SHA256 composite multipart integrity to the existing
|
|
20
|
+
uploader: supplied part checksums are validated before storage, completion checks
|
|
21
|
+
each part and its ordering, and the composite checksum survives HEAD/GET for
|
|
22
|
+
reconciliation. Completion conditions use the backend's atomic conditional write.
|
|
23
|
+
Overwriting an object does not inherit the previous object's checksum metadata.
|
|
24
|
+
This patch supports SHA256/COMPOSITE; it does not implement every S3 checksum
|
|
25
|
+
algorithm or streaming checksum trailers.
|
|
26
|
+
|
|
27
|
+
Run `./test.sh` from this directory to exercise real HTTP requests against the
|
|
28
|
+
same patched sources used in the image, including corrupt retries, conditional
|
|
29
|
+
completion races, abort, metadata reconciliation and unchecksummed uploads.
|
|
30
|
+
The test runs in a temporary vendor directory and enables Go's race detector.
|
|
31
|
+
|
|
19
32
|
From `sdk`, run the real-image contract on a Linux Docker host:
|
|
20
33
|
|
|
21
34
|
```
|
|
@@ -11,6 +11,10 @@ import (
|
|
|
11
11
|
)
|
|
12
12
|
|
|
13
13
|
func main() {
|
|
14
|
+
log.Fatal(http.ListenAndServe(":9090", newServer()))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func newServer() http.Handler {
|
|
14
18
|
// VM snapshots include RAM: buckets, versions and in-flight multipart state
|
|
15
19
|
// fork together. A fresh process starts empty, as S3Mock did by default.
|
|
16
20
|
// Unlike the Bolt backend, s3mem also supports bucket versioning.
|
|
@@ -21,7 +25,7 @@ func main() {
|
|
|
21
25
|
gofakes3.WithAutoBucket(false),
|
|
22
26
|
gofakes3.WithLogger(gofakes3.GlobalLog()),
|
|
23
27
|
).Server()
|
|
24
|
-
|
|
28
|
+
return cors(server)
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
// Preserve S3Mock's permissive browser uploads, including arbitrary metadata
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"crypto/sha256"
|
|
6
|
+
"encoding/base64"
|
|
7
|
+
"encoding/xml"
|
|
8
|
+
"fmt"
|
|
9
|
+
"io"
|
|
10
|
+
"net/http"
|
|
11
|
+
"net/http/httptest"
|
|
12
|
+
"strings"
|
|
13
|
+
"sync"
|
|
14
|
+
"testing"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
type s3Response struct {
|
|
18
|
+
status int
|
|
19
|
+
header http.Header
|
|
20
|
+
body []byte
|
|
21
|
+
}
|
|
22
|
+
type s3Probe struct {
|
|
23
|
+
t *testing.T
|
|
24
|
+
server *httptest.Server
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func newProbe(t *testing.T) *s3Probe {
|
|
28
|
+
t.Helper()
|
|
29
|
+
p := &s3Probe{t, httptest.NewServer(newServer())}
|
|
30
|
+
t.Cleanup(p.server.Close)
|
|
31
|
+
p.request("PUT", "/uploads", nil, nil, 200)
|
|
32
|
+
return p
|
|
33
|
+
}
|
|
34
|
+
func (p *s3Probe) request(method, path string, body []byte, headers map[string]string, want int) s3Response {
|
|
35
|
+
p.t.Helper()
|
|
36
|
+
req, err := http.NewRequest(method, p.server.URL+path, bytes.NewReader(body))
|
|
37
|
+
if err != nil {
|
|
38
|
+
p.t.Fatal(err)
|
|
39
|
+
}
|
|
40
|
+
for k, v := range headers {
|
|
41
|
+
req.Header.Set(k, v)
|
|
42
|
+
}
|
|
43
|
+
res, err := p.server.Client().Do(req)
|
|
44
|
+
if err != nil {
|
|
45
|
+
p.t.Fatal(err)
|
|
46
|
+
}
|
|
47
|
+
defer res.Body.Close()
|
|
48
|
+
data, err := io.ReadAll(res.Body)
|
|
49
|
+
if err != nil {
|
|
50
|
+
p.t.Fatal(err)
|
|
51
|
+
}
|
|
52
|
+
if res.StatusCode != want {
|
|
53
|
+
p.t.Fatalf("%s %s: got %d want %d: %s", method, path, res.StatusCode, want, data)
|
|
54
|
+
}
|
|
55
|
+
return s3Response{res.StatusCode, res.Header, data}
|
|
56
|
+
}
|
|
57
|
+
func sum(body []byte) string {
|
|
58
|
+
digest := sha256.Sum256(body)
|
|
59
|
+
return base64.StdEncoding.EncodeToString(digest[:])
|
|
60
|
+
}
|
|
61
|
+
func (p *s3Probe) start(key string, checksum bool) string {
|
|
62
|
+
p.t.Helper()
|
|
63
|
+
headers := map[string]string{"Cache-Control": "private, max-age=60"}
|
|
64
|
+
if checksum {
|
|
65
|
+
headers["x-amz-checksum-algorithm"] = "SHA256"
|
|
66
|
+
headers["x-amz-checksum-type"] = "COMPOSITE"
|
|
67
|
+
}
|
|
68
|
+
res := p.request("POST", "/uploads/"+key+"?uploads", nil, headers, 200)
|
|
69
|
+
var out struct {
|
|
70
|
+
UploadID string `xml:"UploadId"`
|
|
71
|
+
}
|
|
72
|
+
if err := xml.Unmarshal(res.body, &out); err != nil || out.UploadID == "" {
|
|
73
|
+
p.t.Fatalf("initiate: %s, %v", res.body, err)
|
|
74
|
+
}
|
|
75
|
+
return "/uploads/" + key + "?uploadId=" + out.UploadID
|
|
76
|
+
}
|
|
77
|
+
func (p *s3Probe) part(path string, number int, body []byte, checksum bool) string {
|
|
78
|
+
p.t.Helper()
|
|
79
|
+
headers := map[string]string{}
|
|
80
|
+
if checksum {
|
|
81
|
+
headers["x-amz-checksum-sha256"] = sum(body)
|
|
82
|
+
}
|
|
83
|
+
res := p.request("PUT", fmt.Sprintf("%s&partNumber=%d", path, number), body, headers, 200)
|
|
84
|
+
if checksum && res.header.Get("x-amz-checksum-sha256") != sum(body) {
|
|
85
|
+
p.t.Fatalf("part checksum acknowledgement: %v", res.header)
|
|
86
|
+
}
|
|
87
|
+
tag := res.header.Get("ETag")
|
|
88
|
+
if tag == "" {
|
|
89
|
+
p.t.Fatal("missing ETag")
|
|
90
|
+
}
|
|
91
|
+
digest := ""
|
|
92
|
+
if checksum {
|
|
93
|
+
digest = "<ChecksumSHA256>" + sum(body) + "</ChecksumSHA256>"
|
|
94
|
+
}
|
|
95
|
+
return fmt.Sprintf("<Part><PartNumber>%d</PartNumber><ETag>%s</ETag>%s</Part>", number, tag, digest)
|
|
96
|
+
}
|
|
97
|
+
func completion(parts ...string) []byte {
|
|
98
|
+
return []byte("<CompleteMultipartUpload>" + strings.Join(parts, "") + "</CompleteMultipartUpload>")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func TestMultipartSHA256IntegrityAndReconciliation(t *testing.T) {
|
|
102
|
+
p := newProbe(t)
|
|
103
|
+
path := p.start("large", true)
|
|
104
|
+
first := bytes.Repeat([]byte("a"), 8*1024*1024)
|
|
105
|
+
last := bytes.Repeat([]byte("b"), 1024*1024)
|
|
106
|
+
part1 := p.part(path, 1, first, true)
|
|
107
|
+
// A rejected retry must not replace the already acknowledged part.
|
|
108
|
+
res := p.request("PUT", path+"&partNumber=1", []byte("corrupt"), map[string]string{"x-amz-checksum-sha256": sum(first)}, 400)
|
|
109
|
+
if !bytes.Contains(res.body, []byte("BadDigest")) {
|
|
110
|
+
t.Fatalf("corrupt part: %s", res.body)
|
|
111
|
+
}
|
|
112
|
+
part2 := p.part(path, 2, last, true)
|
|
113
|
+
for _, parts := range [][]string{{part2, part1}, {part1, part1}, {strings.Replace(part1, "<PartNumber>1", "<PartNumber>-1", 1)}, {strings.Replace(part1, sum(first), sum(last), 1), part2}, {strings.Replace(part1, "<ETag>", "<ETag>bad", 1), part2}} {
|
|
114
|
+
p.request("POST", path, completion(parts...), nil, 400)
|
|
115
|
+
}
|
|
116
|
+
p.request("POST", path, completion(part1, part2), map[string]string{"x-amz-checksum-type": "FULL_OBJECT"}, 400)
|
|
117
|
+
p.request("POST", path, completion(part1, part2), map[string]string{"x-amz-checksum-sha256": "wrong"}, 400)
|
|
118
|
+
a, b := sha256.Sum256(first), sha256.Sum256(last)
|
|
119
|
+
expected := sum(append(a[:], b[:]...)) + "-2"
|
|
120
|
+
res = p.request("POST", path, completion(part1, part2), map[string]string{"If-None-Match": "*", "x-amz-checksum-type": "COMPOSITE", "x-amz-checksum-sha256": expected}, 200)
|
|
121
|
+
var completed struct {
|
|
122
|
+
ChecksumSHA256 string
|
|
123
|
+
ChecksumType string
|
|
124
|
+
}
|
|
125
|
+
if err := xml.Unmarshal(res.body, &completed); err != nil {
|
|
126
|
+
t.Fatal(err)
|
|
127
|
+
}
|
|
128
|
+
if completed.ChecksumSHA256 != expected || completed.ChecksumType != "COMPOSITE" {
|
|
129
|
+
t.Fatalf("complete: %s", res.body)
|
|
130
|
+
}
|
|
131
|
+
// Reconcile using retained metadata after an unobserved completion response.
|
|
132
|
+
head := p.request("HEAD", "/uploads/large", nil, map[string]string{"x-amz-checksum-mode": "ENABLED"}, 200)
|
|
133
|
+
if head.header.Get("x-amz-checksum-sha256") != expected || head.header.Get("x-amz-checksum-type") != "COMPOSITE" || head.header.Get("Content-Length") != fmt.Sprint(len(first)+len(last)) || head.header.Get("Cache-Control") != "private, max-age=60" {
|
|
134
|
+
t.Fatalf("reconciliation: %v", head.header)
|
|
135
|
+
}
|
|
136
|
+
got := p.request("GET", "/uploads/large", nil, nil, 200)
|
|
137
|
+
if !bytes.Equal(got.body, append(first, last...)) {
|
|
138
|
+
t.Fatal("completed bytes differ")
|
|
139
|
+
}
|
|
140
|
+
p.request("PUT", path+"&partNumber=1", first, nil, 404)
|
|
141
|
+
// An ordinary overwrite must not inherit an obsolete composite checksum.
|
|
142
|
+
p.request("PUT", "/uploads/large", []byte("replacement"), nil, 200)
|
|
143
|
+
head = p.request("HEAD", "/uploads/large", nil, nil, 200)
|
|
144
|
+
if head.header.Get("x-amz-checksum-sha256") != "" || head.header.Get("x-amz-checksum-type") != "" {
|
|
145
|
+
t.Fatal("overwrite retained stale checksum")
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func TestMultipartConditionalCompletionIsAtomic(t *testing.T) {
|
|
150
|
+
p := newProbe(t)
|
|
151
|
+
paths := []string{p.start("same", true), p.start("same", true)}
|
|
152
|
+
parts := []string{p.part(paths[0], 1, []byte("first"), true), p.part(paths[1], 1, []byte("second"), true)}
|
|
153
|
+
statuses := make(chan int, 2)
|
|
154
|
+
var wg sync.WaitGroup
|
|
155
|
+
for i := range paths {
|
|
156
|
+
wg.Add(1)
|
|
157
|
+
go func(i int) {
|
|
158
|
+
defer wg.Done()
|
|
159
|
+
req, _ := http.NewRequest("POST", p.server.URL+paths[i], bytes.NewReader(completion(parts[i])))
|
|
160
|
+
req.Header.Set("If-None-Match", "*")
|
|
161
|
+
res, err := p.server.Client().Do(req)
|
|
162
|
+
if err != nil {
|
|
163
|
+
statuses <- 0
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
defer res.Body.Close()
|
|
167
|
+
io.Copy(io.Discard, res.Body)
|
|
168
|
+
statuses <- res.StatusCode
|
|
169
|
+
}(i)
|
|
170
|
+
}
|
|
171
|
+
wg.Wait()
|
|
172
|
+
close(statuses)
|
|
173
|
+
counts := map[int]int{}
|
|
174
|
+
for s := range statuses {
|
|
175
|
+
counts[s]++
|
|
176
|
+
}
|
|
177
|
+
if counts[200] != 1 || counts[412] != 1 {
|
|
178
|
+
t.Fatalf("competing creates: %v", counts)
|
|
179
|
+
}
|
|
180
|
+
body := p.request("GET", "/uploads/same", nil, nil, 200).body
|
|
181
|
+
if string(body) != "first" && string(body) != "second" {
|
|
182
|
+
t.Fatalf("bad winner: %s", body)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
func TestMultipartAbortAndLegacyUpload(t *testing.T) {
|
|
187
|
+
p := newProbe(t)
|
|
188
|
+
path := p.start("aborted", true)
|
|
189
|
+
part := p.part(path, 1, []byte("abc"), true)
|
|
190
|
+
p.request("DELETE", path, nil, nil, 204)
|
|
191
|
+
p.request("POST", path, completion(part), nil, 404)
|
|
192
|
+
p.request("HEAD", "/uploads/aborted", nil, nil, 404)
|
|
193
|
+
path = p.start("legacy", false)
|
|
194
|
+
part = p.part(path, 1, []byte("legacy"), false)
|
|
195
|
+
p.request("POST", path, completion(part), nil, 200)
|
|
196
|
+
if got := p.request("GET", "/uploads/legacy", nil, nil, 200); string(got.body) != "legacy" {
|
|
197
|
+
t.Fatal("legacy multipart changed")
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
--- a/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
|
|
2
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
|
|
3
|
+
@@ -933,6 +933,14 @@
|
|
4
|
+
return err
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
+ if meta["X-Amz-Checksum-Algorithm"] == "SHA256" {
|
|
8
|
+
+ if kind := meta["X-Amz-Checksum-Type"]; kind != "" && kind != "COMPOSITE" {
|
|
9
|
+
+ return ErrInvalidArgument
|
|
10
|
+
+ }
|
|
11
|
+
+ meta["X-Amz-Checksum-Type"] = "COMPOSITE"
|
|
12
|
+
+ w.Header().Set("x-amz-checksum-algorithm", "SHA256")
|
|
13
|
+
+ w.Header().Set("x-amz-checksum-type", "COMPOSITE")
|
|
14
|
+
+ }
|
|
15
|
+
uploadID, err := g.uploader.CreateMultipartUpload(bucket, object, meta)
|
|
16
|
+
if err != nil {
|
|
17
|
+
return err
|
|
18
|
+
@@ -983,12 +991,20 @@
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
+ var checksum *sha256PartReader
|
|
23
|
+
+ if expected := r.Header.Get("x-amz-checksum-sha256"); expected != "" {
|
|
24
|
+
+ checksum = newSHA256PartReader(rdr, expected)
|
|
25
|
+
+ rdr = checksum
|
|
26
|
+
+ }
|
|
27
|
+
etag, err := g.uploader.UploadPart(bucket, object, uploadID, int(partNumber), r.ContentLength, rdr)
|
|
28
|
+
if err != nil {
|
|
29
|
+
return err
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
w.Header().Add("ETag", etag)
|
|
33
|
+
+ if checksum != nil {
|
|
34
|
+
+ w.Header().Set("x-amz-checksum-sha256", checksum.sum())
|
|
35
|
+
+ }
|
|
36
|
+
return nil
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@@ -1009,6 +1025,13 @@
|
|
40
|
+
return err
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
+ var err error
|
|
44
|
+
+ in.Conditions, err = parsePutConditions(r.Header)
|
|
45
|
+
+ if err != nil {
|
|
46
|
+
+ return err
|
|
47
|
+
+ }
|
|
48
|
+
+ in.ChecksumSHA256 = r.Header.Get("x-amz-checksum-sha256")
|
|
49
|
+
+ in.ChecksumType = r.Header.Get("x-amz-checksum-type")
|
|
50
|
+
versionID, etag, err := g.uploader.CompleteMultipartUpload(bucket, object, uploadID, &in)
|
|
51
|
+
if err != nil {
|
|
52
|
+
return err
|
|
53
|
+
@@ -1031,10 +1054,12 @@
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return g.xmlEncoder(w).Encode(&CompleteMultipartUploadResult{
|
|
57
|
+
- ETag: etag,
|
|
58
|
+
- Bucket: bucket,
|
|
59
|
+
- Key: object,
|
|
60
|
+
- Location: location,
|
|
61
|
+
+ ETag: etag,
|
|
62
|
+
+ Bucket: bucket,
|
|
63
|
+
+ Key: object,
|
|
64
|
+
+ Location: location,
|
|
65
|
+
+ ChecksumSHA256: in.ChecksumSHA256,
|
|
66
|
+
+ ChecksumType: in.ChecksumType,
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
--- a/vendor/github.com/johannesboyne/gofakes3/uploader.go
|
|
71
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/uploader.go
|
|
72
|
+
@@ -3,6 +3,8 @@
|
|
73
|
+
import (
|
|
74
|
+
"bytes"
|
|
75
|
+
"crypto/md5"
|
|
76
|
+
+ "crypto/sha256"
|
|
77
|
+
+ "encoding/base64"
|
|
78
|
+
"encoding/hex"
|
|
79
|
+
"fmt"
|
|
80
|
+
"io"
|
|
81
|
+
@@ -428,13 +430,26 @@
|
|
82
|
+
return "", "", ErrInvalidPart
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
- if !input.partsAreSorted() {
|
|
86
|
+
+ if len(input.Parts) == 0 || !input.partsAreSorted() {
|
|
87
|
+
return "", "", ErrInvalidPartOrder
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
var size int64
|
|
91
|
+
|
|
92
|
+
- for _, inPart := range input.Parts {
|
|
93
|
+
+ composite := sha256.New()
|
|
94
|
+
+ withSHA256 := mpu.Meta["X-Amz-Checksum-Algorithm"] == "SHA256"
|
|
95
|
+
+ if withSHA256 && input.ChecksumType != "" && input.ChecksumType != "COMPOSITE" {
|
|
96
|
+
+ return "", "", ErrBadDigest
|
|
97
|
+
+ }
|
|
98
|
+
+ previous := 0
|
|
99
|
+
+ for i, inPart := range input.Parts {
|
|
100
|
+
+ if inPart.PartNumber <= previous {
|
|
101
|
+
+ return "", "", ErrInvalidPartOrder
|
|
102
|
+
+ }
|
|
103
|
+
+ previous = inPart.PartNumber
|
|
104
|
+
+ if withSHA256 && inPart.PartNumber != i+1 {
|
|
105
|
+
+ return "", "", ErrInvalidPartOrder
|
|
106
|
+
+ }
|
|
107
|
+
if inPart.PartNumber >= mpuPartsLen || mpu.parts[inPart.PartNumber] == nil {
|
|
108
|
+
return "", "", ErrorMessagef(ErrInvalidPart, "unexpected part number %d in complete request", inPart.PartNumber)
|
|
109
|
+
}
|
|
110
|
+
@@ -444,6 +459,13 @@
|
|
111
|
+
return "", "", ErrorMessagef(ErrInvalidPart, "unexpected part etag for number %d in complete request", inPart.PartNumber)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
+ if withSHA256 {
|
|
115
|
+
+ digest := sha256.Sum256(upPart.Body)
|
|
116
|
+
+ if inPart.ChecksumSHA256 != base64.StdEncoding.EncodeToString(digest[:]) {
|
|
117
|
+
+ return "", "", ErrBadDigest
|
|
118
|
+
+ }
|
|
119
|
+
+ composite.Write(digest[:])
|
|
120
|
+
+ }
|
|
121
|
+
size += int64(len(upPart.Body))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@@ -461,7 +483,20 @@
|
|
125
|
+
|
|
126
|
+
etag = fmt.Sprintf(`"%s-%d"`, hex.EncodeToString(hash.Sum(nil)), len(input.Parts))
|
|
127
|
+
|
|
128
|
+
- result, err := u.storage.PutObject(bucket, object, mpu.Meta, bytes.NewReader(body), int64(len(body)), nil)
|
|
129
|
+
+ if withSHA256 {
|
|
130
|
+
+ checksum := fmt.Sprintf("%s-%d", base64.StdEncoding.EncodeToString(composite.Sum(nil)), len(input.Parts))
|
|
131
|
+
+ if input.ChecksumSHA256 != "" && input.ChecksumSHA256 != checksum {
|
|
132
|
+
+ return "", "", ErrBadDigest
|
|
133
|
+
+ }
|
|
134
|
+
+ input.ChecksumSHA256 = checksum
|
|
135
|
+
+ input.ChecksumType = "COMPOSITE"
|
|
136
|
+
+ mpu.Meta["X-Amz-Checksum-Sha256"] = checksum
|
|
137
|
+
+ mpu.Meta["X-Amz-Checksum-Type"] = "COMPOSITE"
|
|
138
|
+
+ } else {
|
|
139
|
+
+ input.ChecksumSHA256 = ""
|
|
140
|
+
+ input.ChecksumType = ""
|
|
141
|
+
+ }
|
|
142
|
+
+ result, err := u.storage.PutObject(bucket, object, mpu.Meta, bytes.NewReader(body), int64(len(body)), input.Conditions)
|
|
143
|
+
if err != nil {
|
|
144
|
+
return "", "", err
|
|
145
|
+
}
|
|
146
|
+
--- a/vendor/github.com/johannesboyne/gofakes3/messages.go
|
|
147
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/messages.go
|
|
148
|
+
@@ -48,12 +48,16 @@
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type CompletedPart struct {
|
|
152
|
+
- PartNumber int `xml:"PartNumber"`
|
|
153
|
+
- ETag string `xml:"ETag"`
|
|
154
|
+
+ ChecksumSHA256 string `xml:"ChecksumSHA256,omitempty"`
|
|
155
|
+
+ PartNumber int `xml:"PartNumber"`
|
|
156
|
+
+ ETag string `xml:"ETag"`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type CompleteMultipartUploadRequest struct {
|
|
160
|
+
- Parts []CompletedPart `xml:"Part"`
|
|
161
|
+
+ Conditions *PutConditions `xml:"-"`
|
|
162
|
+
+ ChecksumSHA256 string `xml:"-"`
|
|
163
|
+
+ ChecksumType string `xml:"-"`
|
|
164
|
+
+ Parts []CompletedPart `xml:"Part"`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func (c CompleteMultipartUploadRequest) partsAreSorted() bool {
|
|
168
|
+
@@ -70,10 +74,12 @@
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
type CompleteMultipartUploadResult struct {
|
|
172
|
+
- Location string `xml:"Location"`
|
|
173
|
+
- Bucket string `xml:"Bucket"`
|
|
174
|
+
- Key string `xml:"Key"`
|
|
175
|
+
- ETag string `xml:"ETag"`
|
|
176
|
+
+ ChecksumSHA256 string `xml:"ChecksumSHA256,omitempty"`
|
|
177
|
+
+ ChecksumType string `xml:"ChecksumType,omitempty"`
|
|
178
|
+
+ Location string `xml:"Location"`
|
|
179
|
+
+ Bucket string `xml:"Bucket"`
|
|
180
|
+
+ Key string `xml:"Key"`
|
|
181
|
+
+ ETag string `xml:"ETag"`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
type Content struct {
|
|
185
|
+
--- a/vendor/github.com/johannesboyne/gofakes3/checksum.go
|
|
186
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/checksum.go
|
|
187
|
+
@@ -0,0 +1,32 @@
|
|
188
|
+
+package gofakes3
|
|
189
|
+
+
|
|
190
|
+
+import (
|
|
191
|
+
+ "crypto/sha256"
|
|
192
|
+
+ "encoding/base64"
|
|
193
|
+
+ "hash"
|
|
194
|
+
+ "io"
|
|
195
|
+
+)
|
|
196
|
+
+
|
|
197
|
+
+// Validate before UploadPart commits bytes to the existing multipart store.
|
|
198
|
+
+type sha256PartReader struct {
|
|
199
|
+
+ input io.Reader
|
|
200
|
+
+ digest hash.Hash
|
|
201
|
+
+ expected string
|
|
202
|
+
+}
|
|
203
|
+
+
|
|
204
|
+
+func newSHA256PartReader(input io.Reader, expected string) *sha256PartReader {
|
|
205
|
+
+ return &sha256PartReader{input: input, digest: sha256.New(), expected: expected}
|
|
206
|
+
+}
|
|
207
|
+
+
|
|
208
|
+
+func (r *sha256PartReader) sum() string {
|
|
209
|
+
+ return base64.StdEncoding.EncodeToString(r.digest.Sum(nil))
|
|
210
|
+
+}
|
|
211
|
+
+
|
|
212
|
+
+func (r *sha256PartReader) Read(p []byte) (int, error) {
|
|
213
|
+
+ n, err := r.input.Read(p)
|
|
214
|
+
+ r.digest.Write(p[:n])
|
|
215
|
+
+ if err == io.EOF && r.sum() != r.expected {
|
|
216
|
+
+ return n, ErrBadDigest
|
|
217
|
+
+ }
|
|
218
|
+
+ return n, err
|
|
219
|
+
+}
|
|
220
|
+
--- a/vendor/github.com/johannesboyne/gofakes3/backend.go
|
|
221
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/backend.go
|
|
222
|
+
@@ -3,6 +3,7 @@
|
|
223
|
+
import (
|
|
224
|
+
"encoding/hex"
|
|
225
|
+
"errors"
|
|
226
|
+
+ "strings"
|
|
227
|
+
|
|
228
|
+
"io"
|
|
229
|
+
"time"
|
|
230
|
+
@@ -434,6 +435,11 @@
|
|
231
|
+
// carry over metadata if it exists
|
|
232
|
+
if existingObj != nil {
|
|
233
|
+
for k, v := range existingObj.Metadata {
|
|
234
|
+
+ // Integrity metadata belongs to the bytes being written, never a
|
|
235
|
+
+ // previous version of this key. CopyObject supplies its own metadata.
|
|
236
|
+
+ if strings.HasPrefix(k, "X-Amz-Checksum-") {
|
|
237
|
+
+ continue
|
|
238
|
+
+ }
|
|
239
|
+
// new metadata overwrites old but keep the rest
|
|
240
|
+
// TODO: check how metadata can be deleted?!
|
|
241
|
+
if _, ok := meta[k]; !ok {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Exercise the exact vendored compatibility patches used by the runtime image.
|
|
3
|
+
set -eu
|
|
4
|
+
source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
5
|
+
test_dir=$(mktemp -d)
|
|
6
|
+
trap 'rm -rf "$test_dir"' EXIT HUP INT TERM
|
|
7
|
+
cp "$source_dir"/*.go "$source_dir/go.mod" "$source_dir/go.sum" "$source_dir"/*.patch "$test_dir/"
|
|
8
|
+
cd "$test_dir"
|
|
9
|
+
go mod vendor
|
|
10
|
+
patch -p1 < metadata.patch
|
|
11
|
+
patch -p1 < multipart.patch
|
|
12
|
+
go test -mod=vendor -race ./...
|
package/dist/components/s3.js
CHANGED
|
@@ -8,11 +8,11 @@ const S3_DOCKERFILE = `
|
|
|
8
8
|
FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS build
|
|
9
9
|
RUN apk add --no-cache patch
|
|
10
10
|
WORKDIR /src
|
|
11
|
-
${["go.mod", "go.sum", "main.go", "metadata.patch"].map((file) => {
|
|
11
|
+
${["go.mod", "go.sum", "main.go", "metadata.patch", "multipart.patch"].map((file) => {
|
|
12
12
|
const data = readFileSync(new URL(`./s3-runtime/${file}`, import.meta.url)).toString("base64");
|
|
13
13
|
return `RUN printf '%s' '${data}' | base64 -d > ${file}`;
|
|
14
14
|
}).join("\n")}
|
|
15
|
-
RUN go mod vendor && patch -p1 < metadata.patch
|
|
15
|
+
RUN go mod vendor && patch -p1 < metadata.patch && patch -p1 < multipart.patch
|
|
16
16
|
RUN CGO_ENABLED=0 go build -mod=vendor -trimpath -ldflags='-s -w' -o /s3-server .
|
|
17
17
|
FROM scratch
|
|
18
18
|
COPY --from=build /s3-server /s3-server
|
|
@@ -19,13 +19,17 @@ export declare function validateStickyConfig(config: StickyConsumerConfig): void
|
|
|
19
19
|
* only minFreeSpace, BuildKit 0.32.2 sets keepBytes == totalSize when healthy,
|
|
20
20
|
* and pruneOnce's strict '<' check can still evict a record on every GC pass.
|
|
21
21
|
* Actual filesystem capacity is smaller than the device due to metadata. */
|
|
22
|
-
export declare function stickyBuildkitConfig(config: StickyConsumerConfig): string;
|
|
22
|
+
export declare function stickyBuildkitConfig(config: StickyConsumerConfig, reservedBytes?: number): string;
|
|
23
23
|
export declare function initializeStickyConsumer(config: StickyConsumerConfig): Promise<void>;
|
|
24
24
|
/** Builds can finish before automatic GC restores its free-space reserve.
|
|
25
25
|
* Reclaim only unused BuildKit records before project setup adds runtime data.
|
|
26
26
|
* Docker images, containers, volumes and nested Kubernetes stores stay live.
|
|
27
27
|
*/
|
|
28
|
-
export declare function ensureBuildHeadroom(config: StickyConsumerConfig, run?: typeof exec,
|
|
28
|
+
export declare function ensureBuildHeadroom(config: StickyConsumerConfig, run?: typeof exec, usage?: () => Promise<{
|
|
29
|
+
freeBytes: number;
|
|
30
|
+
totalBytes: number;
|
|
31
|
+
reservedBytes: number;
|
|
32
|
+
}>): Promise<void>;
|
|
29
33
|
export declare function ensureStickyBuildHeadroom(): Promise<void>;
|
|
30
34
|
/** Filesystem pressure takes precedence over cache age/BuildKit record sizes.
|
|
31
35
|
* Only called after runtime containers/volumes have been removed on the
|
|
@@ -34,6 +38,7 @@ export declare function ensureStickyBuildHeadroom(): Promise<void>;
|
|
|
34
38
|
export declare function collectStickyCache(config: StickyConsumerConfig, retention: StickyImageRetention, run?: typeof exec, usage?: () => Promise<{
|
|
35
39
|
freeBytes: number;
|
|
36
40
|
totalBytes: number;
|
|
41
|
+
reservedBytes?: number;
|
|
37
42
|
}>, kubernetes?: KubernetesCacheMaintenance): Promise<void>;
|
|
38
43
|
/** Only on an isolated maintenance clone; never on the live ready environment. */
|
|
39
44
|
export declare function prepareStickyConsumer(artifact?: boolean): Promise<{
|
|
@@ -45,7 +45,7 @@ export function validateStickyConfig(config) {
|
|
|
45
45
|
* only minFreeSpace, BuildKit 0.32.2 sets keepBytes == totalSize when healthy,
|
|
46
46
|
* and pruneOnce's strict '<' check can still evict a record on every GC pass.
|
|
47
47
|
* Actual filesystem capacity is smaller than the device due to metadata. */
|
|
48
|
-
export function stickyBuildkitConfig(config) {
|
|
48
|
+
export function stickyBuildkitConfig(config, reservedBytes = 0) {
|
|
49
49
|
validateStickyConfig(config);
|
|
50
50
|
return `root = "${config.path}/buildkit"
|
|
51
51
|
# This standalone BuildKit does not inherit dockerd's registry mirrors.
|
|
@@ -65,7 +65,7 @@ export function stickyBuildkitConfig(config) {
|
|
|
65
65
|
[[worker.containerd.gcpolicy]]
|
|
66
66
|
all = true
|
|
67
67
|
maxUsedSpace = ${config.capacityBytes}
|
|
68
|
-
minFreeSpace = ${config.headroomBytes}
|
|
68
|
+
minFreeSpace = ${config.headroomBytes + reservedBytes}
|
|
69
69
|
`;
|
|
70
70
|
}
|
|
71
71
|
export async function initializeStickyConsumer(config) {
|
|
@@ -102,7 +102,7 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
|
|
|
102
102
|
dockerConfig.features = { "containerd-snapshotter": true };
|
|
103
103
|
dockerConfig["storage-driver"] = "overlayfs";
|
|
104
104
|
await writeFile(`${STICKY_RUNTIME}/docker.json`, JSON.stringify(dockerConfig));
|
|
105
|
-
await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, stickyBuildkitConfig(config));
|
|
105
|
+
await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, stickyBuildkitConfig(config, (await diskUsage(config.path)).reservedBytes));
|
|
106
106
|
const start = async (unit, program, args) => {
|
|
107
107
|
await exec("systemd-run", ["--quiet", "--unit", unit, "-p", "TimeoutStopSec=30",
|
|
108
108
|
// Pinned BuildKit 0.32.2 returns 1 after its normal first-SIGTERM
|
|
@@ -121,23 +121,31 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
|
|
|
121
121
|
await exec("docker", ["buildx", "create", "--name", "spectest-local", "--driver", "remote", "tcp://127.0.0.1:1234"]);
|
|
122
122
|
await exec("docker", ["buildx", "inspect", "--bootstrap", "spectest-local"]);
|
|
123
123
|
}
|
|
124
|
+
async function diskUsage(path) {
|
|
125
|
+
const fs = await statfs(path);
|
|
126
|
+
return { freeBytes: fs.bavail * fs.bsize, totalBytes: fs.blocks * fs.bsize,
|
|
127
|
+
reservedBytes: Math.max(0, fs.bfree - fs.bavail) * fs.bsize };
|
|
128
|
+
}
|
|
129
|
+
// BuildKit 0.32.2 compares MinFreeSpace against statfs.bfree, whereas
|
|
130
|
+
// applications and kubelet need bavail. Include ext4's reserved blocks so
|
|
131
|
+
// pruning restores the required usable space instead of stopping early.
|
|
132
|
+
function pruneTarget(config, usage) {
|
|
133
|
+
return String(Math.ceil((config.headroomBytes + (usage.reservedBytes ?? 0)) / 1e6));
|
|
134
|
+
}
|
|
124
135
|
/** Builds can finish before automatic GC restores its free-space reserve.
|
|
125
136
|
* Reclaim only unused BuildKit records before project setup adds runtime data.
|
|
126
137
|
* Docker images, containers, volumes and nested Kubernetes stores stay live.
|
|
127
138
|
*/
|
|
128
|
-
export async function ensureBuildHeadroom(config, run = exec,
|
|
129
|
-
const fs = await statfs(config.path);
|
|
130
|
-
return fs.bavail * fs.bsize;
|
|
131
|
-
}) {
|
|
139
|
+
export async function ensureBuildHeadroom(config, run = exec, usage = () => diskUsage(config.path)) {
|
|
132
140
|
validateStickyConfig(config);
|
|
133
|
-
let
|
|
134
|
-
for (let pass = 0;
|
|
141
|
+
let fs = await usage();
|
|
142
|
+
for (let pass = 0; fs.freeBytes < config.headroomBytes && pass < 3; pass++) {
|
|
135
143
|
await run("buildctl", ["--addr", "tcp://127.0.0.1:1234", "prune", "--all", "--free-storage",
|
|
136
|
-
|
|
137
|
-
|
|
144
|
+
pruneTarget(config, fs)], 120_000);
|
|
145
|
+
fs = await usage();
|
|
138
146
|
}
|
|
139
|
-
if (
|
|
140
|
-
throw new Error(`environment build left insufficient disk headroom: ${Math.floor(
|
|
147
|
+
if (fs.freeBytes < config.headroomBytes) {
|
|
148
|
+
throw new Error(`environment build left insufficient disk headroom: ${Math.floor(fs.freeBytes / 2 ** 20)} MiB available, ${Math.ceil(config.headroomBytes / 2 ** 20)} MiB required`);
|
|
141
149
|
}
|
|
142
150
|
}
|
|
143
151
|
export async function ensureStickyBuildHeadroom() {
|
|
@@ -153,10 +161,7 @@ export async function ensureStickyBuildHeadroom() {
|
|
|
153
161
|
* Only called after runtime containers/volumes have been removed on the
|
|
154
162
|
* isolated publication clone. Never removes files behind the daemons' backs.
|
|
155
163
|
*/
|
|
156
|
-
export async function collectStickyCache(config, retention, run = exec, usage =
|
|
157
|
-
const fs = await statfs(config.path);
|
|
158
|
-
return { freeBytes: fs.bavail * fs.bsize, totalBytes: fs.blocks * fs.bsize };
|
|
159
|
-
}, kubernetes) {
|
|
164
|
+
export async function collectStickyCache(config, retention, run = exec, usage = () => diskUsage(config.path), kubernetes) {
|
|
160
165
|
validateStickyConfig(config);
|
|
161
166
|
const buildctl = ["--addr", "tcp://127.0.0.1:1234", "prune"];
|
|
162
167
|
// Docker 29's containerd until filter uses local image-record creation;
|
|
@@ -184,7 +189,7 @@ export async function collectStickyCache(config, retention, run = exec, usage =
|
|
|
184
189
|
// a per-consumer cap evicts useful cache even when the disk is mostly empty.
|
|
185
190
|
const pruneBuildkit = async () => {
|
|
186
191
|
await run("buildctl", [...buildctl, "--all", "--free-storage",
|
|
187
|
-
|
|
192
|
+
pruneTarget(config, fs)], 120_000);
|
|
188
193
|
fs = await usage();
|
|
189
194
|
};
|
|
190
195
|
await pruneBuildkit();
|
package/package.json
CHANGED
|
@@ -16,6 +16,19 @@ uploads, multipart initiation and copies. Upstream currently drops these headers
|
|
|
16
16
|
in `metadataHeaders`. The patch applies to the vendored, checksum-verified module;
|
|
17
17
|
a failed patch stops the build. Recheck it when changing the upstream pin.
|
|
18
18
|
|
|
19
|
+
`multipart.patch` adds SHA256 composite multipart integrity to the existing
|
|
20
|
+
uploader: supplied part checksums are validated before storage, completion checks
|
|
21
|
+
each part and its ordering, and the composite checksum survives HEAD/GET for
|
|
22
|
+
reconciliation. Completion conditions use the backend's atomic conditional write.
|
|
23
|
+
Overwriting an object does not inherit the previous object's checksum metadata.
|
|
24
|
+
This patch supports SHA256/COMPOSITE; it does not implement every S3 checksum
|
|
25
|
+
algorithm or streaming checksum trailers.
|
|
26
|
+
|
|
27
|
+
Run `./test.sh` from this directory to exercise real HTTP requests against the
|
|
28
|
+
same patched sources used in the image, including corrupt retries, conditional
|
|
29
|
+
completion races, abort, metadata reconciliation and unchecksummed uploads.
|
|
30
|
+
The test runs in a temporary vendor directory and enables Go's race detector.
|
|
31
|
+
|
|
19
32
|
From `sdk`, run the real-image contract on a Linux Docker host:
|
|
20
33
|
|
|
21
34
|
```
|
|
@@ -11,6 +11,10 @@ import (
|
|
|
11
11
|
)
|
|
12
12
|
|
|
13
13
|
func main() {
|
|
14
|
+
log.Fatal(http.ListenAndServe(":9090", newServer()))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func newServer() http.Handler {
|
|
14
18
|
// VM snapshots include RAM: buckets, versions and in-flight multipart state
|
|
15
19
|
// fork together. A fresh process starts empty, as S3Mock did by default.
|
|
16
20
|
// Unlike the Bolt backend, s3mem also supports bucket versioning.
|
|
@@ -21,7 +25,7 @@ func main() {
|
|
|
21
25
|
gofakes3.WithAutoBucket(false),
|
|
22
26
|
gofakes3.WithLogger(gofakes3.GlobalLog()),
|
|
23
27
|
).Server()
|
|
24
|
-
|
|
28
|
+
return cors(server)
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
// Preserve S3Mock's permissive browser uploads, including arbitrary metadata
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"crypto/sha256"
|
|
6
|
+
"encoding/base64"
|
|
7
|
+
"encoding/xml"
|
|
8
|
+
"fmt"
|
|
9
|
+
"io"
|
|
10
|
+
"net/http"
|
|
11
|
+
"net/http/httptest"
|
|
12
|
+
"strings"
|
|
13
|
+
"sync"
|
|
14
|
+
"testing"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
type s3Response struct {
|
|
18
|
+
status int
|
|
19
|
+
header http.Header
|
|
20
|
+
body []byte
|
|
21
|
+
}
|
|
22
|
+
type s3Probe struct {
|
|
23
|
+
t *testing.T
|
|
24
|
+
server *httptest.Server
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func newProbe(t *testing.T) *s3Probe {
|
|
28
|
+
t.Helper()
|
|
29
|
+
p := &s3Probe{t, httptest.NewServer(newServer())}
|
|
30
|
+
t.Cleanup(p.server.Close)
|
|
31
|
+
p.request("PUT", "/uploads", nil, nil, 200)
|
|
32
|
+
return p
|
|
33
|
+
}
|
|
34
|
+
func (p *s3Probe) request(method, path string, body []byte, headers map[string]string, want int) s3Response {
|
|
35
|
+
p.t.Helper()
|
|
36
|
+
req, err := http.NewRequest(method, p.server.URL+path, bytes.NewReader(body))
|
|
37
|
+
if err != nil {
|
|
38
|
+
p.t.Fatal(err)
|
|
39
|
+
}
|
|
40
|
+
for k, v := range headers {
|
|
41
|
+
req.Header.Set(k, v)
|
|
42
|
+
}
|
|
43
|
+
res, err := p.server.Client().Do(req)
|
|
44
|
+
if err != nil {
|
|
45
|
+
p.t.Fatal(err)
|
|
46
|
+
}
|
|
47
|
+
defer res.Body.Close()
|
|
48
|
+
data, err := io.ReadAll(res.Body)
|
|
49
|
+
if err != nil {
|
|
50
|
+
p.t.Fatal(err)
|
|
51
|
+
}
|
|
52
|
+
if res.StatusCode != want {
|
|
53
|
+
p.t.Fatalf("%s %s: got %d want %d: %s", method, path, res.StatusCode, want, data)
|
|
54
|
+
}
|
|
55
|
+
return s3Response{res.StatusCode, res.Header, data}
|
|
56
|
+
}
|
|
57
|
+
func sum(body []byte) string {
|
|
58
|
+
digest := sha256.Sum256(body)
|
|
59
|
+
return base64.StdEncoding.EncodeToString(digest[:])
|
|
60
|
+
}
|
|
61
|
+
func (p *s3Probe) start(key string, checksum bool) string {
|
|
62
|
+
p.t.Helper()
|
|
63
|
+
headers := map[string]string{"Cache-Control": "private, max-age=60"}
|
|
64
|
+
if checksum {
|
|
65
|
+
headers["x-amz-checksum-algorithm"] = "SHA256"
|
|
66
|
+
headers["x-amz-checksum-type"] = "COMPOSITE"
|
|
67
|
+
}
|
|
68
|
+
res := p.request("POST", "/uploads/"+key+"?uploads", nil, headers, 200)
|
|
69
|
+
var out struct {
|
|
70
|
+
UploadID string `xml:"UploadId"`
|
|
71
|
+
}
|
|
72
|
+
if err := xml.Unmarshal(res.body, &out); err != nil || out.UploadID == "" {
|
|
73
|
+
p.t.Fatalf("initiate: %s, %v", res.body, err)
|
|
74
|
+
}
|
|
75
|
+
return "/uploads/" + key + "?uploadId=" + out.UploadID
|
|
76
|
+
}
|
|
77
|
+
func (p *s3Probe) part(path string, number int, body []byte, checksum bool) string {
|
|
78
|
+
p.t.Helper()
|
|
79
|
+
headers := map[string]string{}
|
|
80
|
+
if checksum {
|
|
81
|
+
headers["x-amz-checksum-sha256"] = sum(body)
|
|
82
|
+
}
|
|
83
|
+
res := p.request("PUT", fmt.Sprintf("%s&partNumber=%d", path, number), body, headers, 200)
|
|
84
|
+
if checksum && res.header.Get("x-amz-checksum-sha256") != sum(body) {
|
|
85
|
+
p.t.Fatalf("part checksum acknowledgement: %v", res.header)
|
|
86
|
+
}
|
|
87
|
+
tag := res.header.Get("ETag")
|
|
88
|
+
if tag == "" {
|
|
89
|
+
p.t.Fatal("missing ETag")
|
|
90
|
+
}
|
|
91
|
+
digest := ""
|
|
92
|
+
if checksum {
|
|
93
|
+
digest = "<ChecksumSHA256>" + sum(body) + "</ChecksumSHA256>"
|
|
94
|
+
}
|
|
95
|
+
return fmt.Sprintf("<Part><PartNumber>%d</PartNumber><ETag>%s</ETag>%s</Part>", number, tag, digest)
|
|
96
|
+
}
|
|
97
|
+
func completion(parts ...string) []byte {
|
|
98
|
+
return []byte("<CompleteMultipartUpload>" + strings.Join(parts, "") + "</CompleteMultipartUpload>")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func TestMultipartSHA256IntegrityAndReconciliation(t *testing.T) {
|
|
102
|
+
p := newProbe(t)
|
|
103
|
+
path := p.start("large", true)
|
|
104
|
+
first := bytes.Repeat([]byte("a"), 8*1024*1024)
|
|
105
|
+
last := bytes.Repeat([]byte("b"), 1024*1024)
|
|
106
|
+
part1 := p.part(path, 1, first, true)
|
|
107
|
+
// A rejected retry must not replace the already acknowledged part.
|
|
108
|
+
res := p.request("PUT", path+"&partNumber=1", []byte("corrupt"), map[string]string{"x-amz-checksum-sha256": sum(first)}, 400)
|
|
109
|
+
if !bytes.Contains(res.body, []byte("BadDigest")) {
|
|
110
|
+
t.Fatalf("corrupt part: %s", res.body)
|
|
111
|
+
}
|
|
112
|
+
part2 := p.part(path, 2, last, true)
|
|
113
|
+
for _, parts := range [][]string{{part2, part1}, {part1, part1}, {strings.Replace(part1, "<PartNumber>1", "<PartNumber>-1", 1)}, {strings.Replace(part1, sum(first), sum(last), 1), part2}, {strings.Replace(part1, "<ETag>", "<ETag>bad", 1), part2}} {
|
|
114
|
+
p.request("POST", path, completion(parts...), nil, 400)
|
|
115
|
+
}
|
|
116
|
+
p.request("POST", path, completion(part1, part2), map[string]string{"x-amz-checksum-type": "FULL_OBJECT"}, 400)
|
|
117
|
+
p.request("POST", path, completion(part1, part2), map[string]string{"x-amz-checksum-sha256": "wrong"}, 400)
|
|
118
|
+
a, b := sha256.Sum256(first), sha256.Sum256(last)
|
|
119
|
+
expected := sum(append(a[:], b[:]...)) + "-2"
|
|
120
|
+
res = p.request("POST", path, completion(part1, part2), map[string]string{"If-None-Match": "*", "x-amz-checksum-type": "COMPOSITE", "x-amz-checksum-sha256": expected}, 200)
|
|
121
|
+
var completed struct {
|
|
122
|
+
ChecksumSHA256 string
|
|
123
|
+
ChecksumType string
|
|
124
|
+
}
|
|
125
|
+
if err := xml.Unmarshal(res.body, &completed); err != nil {
|
|
126
|
+
t.Fatal(err)
|
|
127
|
+
}
|
|
128
|
+
if completed.ChecksumSHA256 != expected || completed.ChecksumType != "COMPOSITE" {
|
|
129
|
+
t.Fatalf("complete: %s", res.body)
|
|
130
|
+
}
|
|
131
|
+
// Reconcile using retained metadata after an unobserved completion response.
|
|
132
|
+
head := p.request("HEAD", "/uploads/large", nil, map[string]string{"x-amz-checksum-mode": "ENABLED"}, 200)
|
|
133
|
+
if head.header.Get("x-amz-checksum-sha256") != expected || head.header.Get("x-amz-checksum-type") != "COMPOSITE" || head.header.Get("Content-Length") != fmt.Sprint(len(first)+len(last)) || head.header.Get("Cache-Control") != "private, max-age=60" {
|
|
134
|
+
t.Fatalf("reconciliation: %v", head.header)
|
|
135
|
+
}
|
|
136
|
+
got := p.request("GET", "/uploads/large", nil, nil, 200)
|
|
137
|
+
if !bytes.Equal(got.body, append(first, last...)) {
|
|
138
|
+
t.Fatal("completed bytes differ")
|
|
139
|
+
}
|
|
140
|
+
p.request("PUT", path+"&partNumber=1", first, nil, 404)
|
|
141
|
+
// An ordinary overwrite must not inherit an obsolete composite checksum.
|
|
142
|
+
p.request("PUT", "/uploads/large", []byte("replacement"), nil, 200)
|
|
143
|
+
head = p.request("HEAD", "/uploads/large", nil, nil, 200)
|
|
144
|
+
if head.header.Get("x-amz-checksum-sha256") != "" || head.header.Get("x-amz-checksum-type") != "" {
|
|
145
|
+
t.Fatal("overwrite retained stale checksum")
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func TestMultipartConditionalCompletionIsAtomic(t *testing.T) {
|
|
150
|
+
p := newProbe(t)
|
|
151
|
+
paths := []string{p.start("same", true), p.start("same", true)}
|
|
152
|
+
parts := []string{p.part(paths[0], 1, []byte("first"), true), p.part(paths[1], 1, []byte("second"), true)}
|
|
153
|
+
statuses := make(chan int, 2)
|
|
154
|
+
var wg sync.WaitGroup
|
|
155
|
+
for i := range paths {
|
|
156
|
+
wg.Add(1)
|
|
157
|
+
go func(i int) {
|
|
158
|
+
defer wg.Done()
|
|
159
|
+
req, _ := http.NewRequest("POST", p.server.URL+paths[i], bytes.NewReader(completion(parts[i])))
|
|
160
|
+
req.Header.Set("If-None-Match", "*")
|
|
161
|
+
res, err := p.server.Client().Do(req)
|
|
162
|
+
if err != nil {
|
|
163
|
+
statuses <- 0
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
defer res.Body.Close()
|
|
167
|
+
io.Copy(io.Discard, res.Body)
|
|
168
|
+
statuses <- res.StatusCode
|
|
169
|
+
}(i)
|
|
170
|
+
}
|
|
171
|
+
wg.Wait()
|
|
172
|
+
close(statuses)
|
|
173
|
+
counts := map[int]int{}
|
|
174
|
+
for s := range statuses {
|
|
175
|
+
counts[s]++
|
|
176
|
+
}
|
|
177
|
+
if counts[200] != 1 || counts[412] != 1 {
|
|
178
|
+
t.Fatalf("competing creates: %v", counts)
|
|
179
|
+
}
|
|
180
|
+
body := p.request("GET", "/uploads/same", nil, nil, 200).body
|
|
181
|
+
if string(body) != "first" && string(body) != "second" {
|
|
182
|
+
t.Fatalf("bad winner: %s", body)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
func TestMultipartAbortAndLegacyUpload(t *testing.T) {
|
|
187
|
+
p := newProbe(t)
|
|
188
|
+
path := p.start("aborted", true)
|
|
189
|
+
part := p.part(path, 1, []byte("abc"), true)
|
|
190
|
+
p.request("DELETE", path, nil, nil, 204)
|
|
191
|
+
p.request("POST", path, completion(part), nil, 404)
|
|
192
|
+
p.request("HEAD", "/uploads/aborted", nil, nil, 404)
|
|
193
|
+
path = p.start("legacy", false)
|
|
194
|
+
part = p.part(path, 1, []byte("legacy"), false)
|
|
195
|
+
p.request("POST", path, completion(part), nil, 200)
|
|
196
|
+
if got := p.request("GET", "/uploads/legacy", nil, nil, 200); string(got.body) != "legacy" {
|
|
197
|
+
t.Fatal("legacy multipart changed")
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
--- a/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
|
|
2
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
|
|
3
|
+
@@ -933,6 +933,14 @@
|
|
4
|
+
return err
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
+ if meta["X-Amz-Checksum-Algorithm"] == "SHA256" {
|
|
8
|
+
+ if kind := meta["X-Amz-Checksum-Type"]; kind != "" && kind != "COMPOSITE" {
|
|
9
|
+
+ return ErrInvalidArgument
|
|
10
|
+
+ }
|
|
11
|
+
+ meta["X-Amz-Checksum-Type"] = "COMPOSITE"
|
|
12
|
+
+ w.Header().Set("x-amz-checksum-algorithm", "SHA256")
|
|
13
|
+
+ w.Header().Set("x-amz-checksum-type", "COMPOSITE")
|
|
14
|
+
+ }
|
|
15
|
+
uploadID, err := g.uploader.CreateMultipartUpload(bucket, object, meta)
|
|
16
|
+
if err != nil {
|
|
17
|
+
return err
|
|
18
|
+
@@ -983,12 +991,20 @@
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
+ var checksum *sha256PartReader
|
|
23
|
+
+ if expected := r.Header.Get("x-amz-checksum-sha256"); expected != "" {
|
|
24
|
+
+ checksum = newSHA256PartReader(rdr, expected)
|
|
25
|
+
+ rdr = checksum
|
|
26
|
+
+ }
|
|
27
|
+
etag, err := g.uploader.UploadPart(bucket, object, uploadID, int(partNumber), r.ContentLength, rdr)
|
|
28
|
+
if err != nil {
|
|
29
|
+
return err
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
w.Header().Add("ETag", etag)
|
|
33
|
+
+ if checksum != nil {
|
|
34
|
+
+ w.Header().Set("x-amz-checksum-sha256", checksum.sum())
|
|
35
|
+
+ }
|
|
36
|
+
return nil
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@@ -1009,6 +1025,13 @@
|
|
40
|
+
return err
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
+ var err error
|
|
44
|
+
+ in.Conditions, err = parsePutConditions(r.Header)
|
|
45
|
+
+ if err != nil {
|
|
46
|
+
+ return err
|
|
47
|
+
+ }
|
|
48
|
+
+ in.ChecksumSHA256 = r.Header.Get("x-amz-checksum-sha256")
|
|
49
|
+
+ in.ChecksumType = r.Header.Get("x-amz-checksum-type")
|
|
50
|
+
versionID, etag, err := g.uploader.CompleteMultipartUpload(bucket, object, uploadID, &in)
|
|
51
|
+
if err != nil {
|
|
52
|
+
return err
|
|
53
|
+
@@ -1031,10 +1054,12 @@
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return g.xmlEncoder(w).Encode(&CompleteMultipartUploadResult{
|
|
57
|
+
- ETag: etag,
|
|
58
|
+
- Bucket: bucket,
|
|
59
|
+
- Key: object,
|
|
60
|
+
- Location: location,
|
|
61
|
+
+ ETag: etag,
|
|
62
|
+
+ Bucket: bucket,
|
|
63
|
+
+ Key: object,
|
|
64
|
+
+ Location: location,
|
|
65
|
+
+ ChecksumSHA256: in.ChecksumSHA256,
|
|
66
|
+
+ ChecksumType: in.ChecksumType,
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
--- a/vendor/github.com/johannesboyne/gofakes3/uploader.go
|
|
71
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/uploader.go
|
|
72
|
+
@@ -3,6 +3,8 @@
|
|
73
|
+
import (
|
|
74
|
+
"bytes"
|
|
75
|
+
"crypto/md5"
|
|
76
|
+
+ "crypto/sha256"
|
|
77
|
+
+ "encoding/base64"
|
|
78
|
+
"encoding/hex"
|
|
79
|
+
"fmt"
|
|
80
|
+
"io"
|
|
81
|
+
@@ -428,13 +430,26 @@
|
|
82
|
+
return "", "", ErrInvalidPart
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
- if !input.partsAreSorted() {
|
|
86
|
+
+ if len(input.Parts) == 0 || !input.partsAreSorted() {
|
|
87
|
+
return "", "", ErrInvalidPartOrder
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
var size int64
|
|
91
|
+
|
|
92
|
+
- for _, inPart := range input.Parts {
|
|
93
|
+
+ composite := sha256.New()
|
|
94
|
+
+ withSHA256 := mpu.Meta["X-Amz-Checksum-Algorithm"] == "SHA256"
|
|
95
|
+
+ if withSHA256 && input.ChecksumType != "" && input.ChecksumType != "COMPOSITE" {
|
|
96
|
+
+ return "", "", ErrBadDigest
|
|
97
|
+
+ }
|
|
98
|
+
+ previous := 0
|
|
99
|
+
+ for i, inPart := range input.Parts {
|
|
100
|
+
+ if inPart.PartNumber <= previous {
|
|
101
|
+
+ return "", "", ErrInvalidPartOrder
|
|
102
|
+
+ }
|
|
103
|
+
+ previous = inPart.PartNumber
|
|
104
|
+
+ if withSHA256 && inPart.PartNumber != i+1 {
|
|
105
|
+
+ return "", "", ErrInvalidPartOrder
|
|
106
|
+
+ }
|
|
107
|
+
if inPart.PartNumber >= mpuPartsLen || mpu.parts[inPart.PartNumber] == nil {
|
|
108
|
+
return "", "", ErrorMessagef(ErrInvalidPart, "unexpected part number %d in complete request", inPart.PartNumber)
|
|
109
|
+
}
|
|
110
|
+
@@ -444,6 +459,13 @@
|
|
111
|
+
return "", "", ErrorMessagef(ErrInvalidPart, "unexpected part etag for number %d in complete request", inPart.PartNumber)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
+ if withSHA256 {
|
|
115
|
+
+ digest := sha256.Sum256(upPart.Body)
|
|
116
|
+
+ if inPart.ChecksumSHA256 != base64.StdEncoding.EncodeToString(digest[:]) {
|
|
117
|
+
+ return "", "", ErrBadDigest
|
|
118
|
+
+ }
|
|
119
|
+
+ composite.Write(digest[:])
|
|
120
|
+
+ }
|
|
121
|
+
size += int64(len(upPart.Body))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@@ -461,7 +483,20 @@
|
|
125
|
+
|
|
126
|
+
etag = fmt.Sprintf(`"%s-%d"`, hex.EncodeToString(hash.Sum(nil)), len(input.Parts))
|
|
127
|
+
|
|
128
|
+
- result, err := u.storage.PutObject(bucket, object, mpu.Meta, bytes.NewReader(body), int64(len(body)), nil)
|
|
129
|
+
+ if withSHA256 {
|
|
130
|
+
+ checksum := fmt.Sprintf("%s-%d", base64.StdEncoding.EncodeToString(composite.Sum(nil)), len(input.Parts))
|
|
131
|
+
+ if input.ChecksumSHA256 != "" && input.ChecksumSHA256 != checksum {
|
|
132
|
+
+ return "", "", ErrBadDigest
|
|
133
|
+
+ }
|
|
134
|
+
+ input.ChecksumSHA256 = checksum
|
|
135
|
+
+ input.ChecksumType = "COMPOSITE"
|
|
136
|
+
+ mpu.Meta["X-Amz-Checksum-Sha256"] = checksum
|
|
137
|
+
+ mpu.Meta["X-Amz-Checksum-Type"] = "COMPOSITE"
|
|
138
|
+
+ } else {
|
|
139
|
+
+ input.ChecksumSHA256 = ""
|
|
140
|
+
+ input.ChecksumType = ""
|
|
141
|
+
+ }
|
|
142
|
+
+ result, err := u.storage.PutObject(bucket, object, mpu.Meta, bytes.NewReader(body), int64(len(body)), input.Conditions)
|
|
143
|
+
if err != nil {
|
|
144
|
+
return "", "", err
|
|
145
|
+
}
|
|
146
|
+
--- a/vendor/github.com/johannesboyne/gofakes3/messages.go
|
|
147
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/messages.go
|
|
148
|
+
@@ -48,12 +48,16 @@
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type CompletedPart struct {
|
|
152
|
+
- PartNumber int `xml:"PartNumber"`
|
|
153
|
+
- ETag string `xml:"ETag"`
|
|
154
|
+
+ ChecksumSHA256 string `xml:"ChecksumSHA256,omitempty"`
|
|
155
|
+
+ PartNumber int `xml:"PartNumber"`
|
|
156
|
+
+ ETag string `xml:"ETag"`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type CompleteMultipartUploadRequest struct {
|
|
160
|
+
- Parts []CompletedPart `xml:"Part"`
|
|
161
|
+
+ Conditions *PutConditions `xml:"-"`
|
|
162
|
+
+ ChecksumSHA256 string `xml:"-"`
|
|
163
|
+
+ ChecksumType string `xml:"-"`
|
|
164
|
+
+ Parts []CompletedPart `xml:"Part"`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func (c CompleteMultipartUploadRequest) partsAreSorted() bool {
|
|
168
|
+
@@ -70,10 +74,12 @@
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
type CompleteMultipartUploadResult struct {
|
|
172
|
+
- Location string `xml:"Location"`
|
|
173
|
+
- Bucket string `xml:"Bucket"`
|
|
174
|
+
- Key string `xml:"Key"`
|
|
175
|
+
- ETag string `xml:"ETag"`
|
|
176
|
+
+ ChecksumSHA256 string `xml:"ChecksumSHA256,omitempty"`
|
|
177
|
+
+ ChecksumType string `xml:"ChecksumType,omitempty"`
|
|
178
|
+
+ Location string `xml:"Location"`
|
|
179
|
+
+ Bucket string `xml:"Bucket"`
|
|
180
|
+
+ Key string `xml:"Key"`
|
|
181
|
+
+ ETag string `xml:"ETag"`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
type Content struct {
|
|
185
|
+
--- a/vendor/github.com/johannesboyne/gofakes3/checksum.go
|
|
186
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/checksum.go
|
|
187
|
+
@@ -0,0 +1,32 @@
|
|
188
|
+
+package gofakes3
|
|
189
|
+
+
|
|
190
|
+
+import (
|
|
191
|
+
+ "crypto/sha256"
|
|
192
|
+
+ "encoding/base64"
|
|
193
|
+
+ "hash"
|
|
194
|
+
+ "io"
|
|
195
|
+
+)
|
|
196
|
+
+
|
|
197
|
+
+// Validate before UploadPart commits bytes to the existing multipart store.
|
|
198
|
+
+type sha256PartReader struct {
|
|
199
|
+
+ input io.Reader
|
|
200
|
+
+ digest hash.Hash
|
|
201
|
+
+ expected string
|
|
202
|
+
+}
|
|
203
|
+
+
|
|
204
|
+
+func newSHA256PartReader(input io.Reader, expected string) *sha256PartReader {
|
|
205
|
+
+ return &sha256PartReader{input: input, digest: sha256.New(), expected: expected}
|
|
206
|
+
+}
|
|
207
|
+
+
|
|
208
|
+
+func (r *sha256PartReader) sum() string {
|
|
209
|
+
+ return base64.StdEncoding.EncodeToString(r.digest.Sum(nil))
|
|
210
|
+
+}
|
|
211
|
+
+
|
|
212
|
+
+func (r *sha256PartReader) Read(p []byte) (int, error) {
|
|
213
|
+
+ n, err := r.input.Read(p)
|
|
214
|
+
+ r.digest.Write(p[:n])
|
|
215
|
+
+ if err == io.EOF && r.sum() != r.expected {
|
|
216
|
+
+ return n, ErrBadDigest
|
|
217
|
+
+ }
|
|
218
|
+
+ return n, err
|
|
219
|
+
+}
|
|
220
|
+
--- a/vendor/github.com/johannesboyne/gofakes3/backend.go
|
|
221
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/backend.go
|
|
222
|
+
@@ -3,6 +3,7 @@
|
|
223
|
+
import (
|
|
224
|
+
"encoding/hex"
|
|
225
|
+
"errors"
|
|
226
|
+
+ "strings"
|
|
227
|
+
|
|
228
|
+
"io"
|
|
229
|
+
"time"
|
|
230
|
+
@@ -434,6 +435,11 @@
|
|
231
|
+
// carry over metadata if it exists
|
|
232
|
+
if existingObj != nil {
|
|
233
|
+
for k, v := range existingObj.Metadata {
|
|
234
|
+
+ // Integrity metadata belongs to the bytes being written, never a
|
|
235
|
+
+ // previous version of this key. CopyObject supplies its own metadata.
|
|
236
|
+
+ if strings.HasPrefix(k, "X-Amz-Checksum-") {
|
|
237
|
+
+ continue
|
|
238
|
+
+ }
|
|
239
|
+
// new metadata overwrites old but keep the rest
|
|
240
|
+
// TODO: check how metadata can be deleted?!
|
|
241
|
+
if _, ok := meta[k]; !ok {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Exercise the exact vendored compatibility patches used by the runtime image.
|
|
3
|
+
set -eu
|
|
4
|
+
source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
5
|
+
test_dir=$(mktemp -d)
|
|
6
|
+
trap 'rm -rf "$test_dir"' EXIT HUP INT TERM
|
|
7
|
+
cp "$source_dir"/*.go "$source_dir/go.mod" "$source_dir/go.sum" "$source_dir"/*.patch "$test_dir/"
|
|
8
|
+
cd "$test_dir"
|
|
9
|
+
go mod vendor
|
|
10
|
+
patch -p1 < metadata.patch
|
|
11
|
+
patch -p1 < multipart.patch
|
|
12
|
+
go test -mod=vendor -race ./...
|
package/src/components/s3.ts
CHANGED
|
@@ -52,11 +52,11 @@ const S3_DOCKERFILE = `
|
|
|
52
52
|
FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS build
|
|
53
53
|
RUN apk add --no-cache patch
|
|
54
54
|
WORKDIR /src
|
|
55
|
-
${["go.mod", "go.sum", "main.go", "metadata.patch"].map((file) => {
|
|
55
|
+
${["go.mod", "go.sum", "main.go", "metadata.patch", "multipart.patch"].map((file) => {
|
|
56
56
|
const data = readFileSync(new URL(`./s3-runtime/${file}`, import.meta.url)).toString("base64");
|
|
57
57
|
return `RUN printf '%s' '${data}' | base64 -d > ${file}`;
|
|
58
58
|
}).join("\n")}
|
|
59
|
-
RUN go mod vendor && patch -p1 < metadata.patch
|
|
59
|
+
RUN go mod vendor && patch -p1 < metadata.patch && patch -p1 < multipart.patch
|
|
60
60
|
RUN CGO_ENABLED=0 go build -mod=vendor -trimpath -ldflags='-s -w' -o /s3-server .
|
|
61
61
|
FROM scratch
|
|
62
62
|
COPY --from=build /s3-server /s3-server
|
|
@@ -20,12 +20,12 @@ test("completed builds preserve healthy caches and restore only missing runtime
|
|
|
20
20
|
free += 10 * GiB;
|
|
21
21
|
return "";
|
|
22
22
|
};
|
|
23
|
-
await ensureBuildHeadroom(config, run, async () => free);
|
|
23
|
+
await ensureBuildHeadroom(config, run, async () => ({ freeBytes: free, totalBytes: 251 * GiB, reservedBytes: 0 }));
|
|
24
24
|
expect(calls).toEqual([]);
|
|
25
25
|
// Tangent's build left 13 GiB before native scratch crossed the
|
|
26
26
|
// kubelet eviction threshold. Containerd reclamation can take passes.
|
|
27
27
|
free = 13 * GiB;
|
|
28
|
-
await ensureBuildHeadroom(config, run, async () => free);
|
|
28
|
+
await ensureBuildHeadroom(config, run, async () => ({ freeBytes: free, totalBytes: 251 * GiB, reservedBytes: 0 }));
|
|
29
29
|
expect(free).toBe(33 * GiB);
|
|
30
30
|
expect(calls).toEqual(Array.from({ length: 2 }, () => ["buildctl", "--addr", "tcp://127.0.0.1:1234",
|
|
31
31
|
"prune", "--all", "--free-storage", String(Math.ceil(32 * GiB / 1e6))]));
|
|
@@ -36,13 +36,13 @@ test("unreclaimable build storage fails before project setup without deleting ru
|
|
|
36
36
|
await expect(ensureBuildHeadroom(config, async (command) => {
|
|
37
37
|
commands.push(command);
|
|
38
38
|
return "";
|
|
39
|
-
}, async () => 13 * GiB)).rejects.toThrow("13312 MiB available, 32768 MiB required");
|
|
39
|
+
}, async () => ({ freeBytes: 13 * GiB, totalBytes: 251 * GiB, reservedBytes: 0 }))).rejects.toThrow("13312 MiB available, 32768 MiB required");
|
|
40
40
|
expect(commands).toEqual(["buildctl", "buildctl", "buildctl"]);
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
test("build headroom checks propagate failed GC and disk observations", async () => {
|
|
44
44
|
await expect(ensureBuildHeadroom(config, async () => { throw new Error("GC unavailable"); },
|
|
45
|
-
async () => 13 * GiB)).rejects.toThrow("GC unavailable");
|
|
45
|
+
async () => ({ freeBytes: 13 * GiB, totalBytes: 251 * GiB, reservedBytes: 0 }))).rejects.toThrow("GC unavailable");
|
|
46
46
|
await expect(ensureBuildHeadroom(config, async () => { throw new Error("must not run"); },
|
|
47
47
|
async () => { throw new Error("filesystem unavailable"); })).rejects.toThrow("filesystem unavailable");
|
|
48
48
|
});
|
|
@@ -195,3 +195,30 @@ test("GC does not remove an image while it supplies the Kubernetes maintenance d
|
|
|
195
195
|
});
|
|
196
196
|
expect(removed).toEqual(["old"]);
|
|
197
197
|
});
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
test("BuildKit thresholds include ext4 reserved blocks for build and publication GC", async () => {
|
|
201
|
+
// The failed Tangent build had 23 GiB available but more than 32 GiB
|
|
202
|
+
// physically free, so an unadjusted BuildKit request reclaimed nothing.
|
|
203
|
+
const reservedBytes = 13 * GiB;
|
|
204
|
+
const worker = (Bun.TOML.parse(stickyBuildkitConfig(config, reservedBytes)) as any).worker.containerd;
|
|
205
|
+
expect(worker.gcpolicy[0].minFreeSpace).toBe(45 * GiB);
|
|
206
|
+
for (const build of [true, false]) {
|
|
207
|
+
let freeBytes = 23 * GiB;
|
|
208
|
+
const requests: number[] = [];
|
|
209
|
+
const run = async (_command: string, args: string[]) => {
|
|
210
|
+
const index = args.indexOf("--free-storage");
|
|
211
|
+
if (index >= 0) {
|
|
212
|
+
requests.push(Number(args[index + 1]) * 1e6);
|
|
213
|
+
freeBytes = 33 * GiB;
|
|
214
|
+
}
|
|
215
|
+
return "";
|
|
216
|
+
};
|
|
217
|
+
const usage = async () => ({ freeBytes, totalBytes: 251 * GiB, reservedBytes });
|
|
218
|
+
if (build) await ensureBuildHeadroom(config, run, usage);
|
|
219
|
+
else await collectStickyCache(config, retention(), run, usage);
|
|
220
|
+
expect(requests).toHaveLength(1);
|
|
221
|
+
expect(requests[0]).toBeGreaterThanOrEqual(45 * GiB);
|
|
222
|
+
expect(requests[0]).toBeLessThan(45 * GiB + 1e6);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
@@ -50,7 +50,7 @@ export function validateStickyConfig(config: StickyConsumerConfig): void {
|
|
|
50
50
|
* only minFreeSpace, BuildKit 0.32.2 sets keepBytes == totalSize when healthy,
|
|
51
51
|
* and pruneOnce's strict '<' check can still evict a record on every GC pass.
|
|
52
52
|
* Actual filesystem capacity is smaller than the device due to metadata. */
|
|
53
|
-
export function stickyBuildkitConfig(config: StickyConsumerConfig): string {
|
|
53
|
+
export function stickyBuildkitConfig(config: StickyConsumerConfig, reservedBytes = 0): string {
|
|
54
54
|
validateStickyConfig(config);
|
|
55
55
|
return `root = "${config.path}/buildkit"
|
|
56
56
|
# This standalone BuildKit does not inherit dockerd's registry mirrors.
|
|
@@ -70,7 +70,7 @@ export function stickyBuildkitConfig(config: StickyConsumerConfig): string {
|
|
|
70
70
|
[[worker.containerd.gcpolicy]]
|
|
71
71
|
all = true
|
|
72
72
|
maxUsedSpace = ${config.capacityBytes}
|
|
73
|
-
minFreeSpace = ${config.headroomBytes}
|
|
73
|
+
minFreeSpace = ${config.headroomBytes + reservedBytes}
|
|
74
74
|
`;
|
|
75
75
|
}
|
|
76
76
|
|
|
@@ -105,7 +105,7 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
|
|
|
105
105
|
dockerConfig.features = { "containerd-snapshotter": true };
|
|
106
106
|
dockerConfig["storage-driver"] = "overlayfs";
|
|
107
107
|
await writeFile(`${STICKY_RUNTIME}/docker.json`, JSON.stringify(dockerConfig));
|
|
108
|
-
await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, stickyBuildkitConfig(config));
|
|
108
|
+
await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, stickyBuildkitConfig(config, (await diskUsage(config.path)).reservedBytes));
|
|
109
109
|
const start = async (unit: string, program: string, args: string[]) => {
|
|
110
110
|
await exec("systemd-run", ["--quiet", "--unit", unit, "-p", "TimeoutStopSec=30",
|
|
111
111
|
// Pinned BuildKit 0.32.2 returns 1 after its normal first-SIGTERM
|
|
@@ -125,6 +125,19 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
|
|
|
125
125
|
await exec("docker", ["buildx", "inspect", "--bootstrap", "spectest-local"]);
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
async function diskUsage(path: string) {
|
|
129
|
+
const fs = await statfs(path);
|
|
130
|
+
return { freeBytes: fs.bavail * fs.bsize, totalBytes: fs.blocks * fs.bsize,
|
|
131
|
+
reservedBytes: Math.max(0, fs.bfree - fs.bavail) * fs.bsize };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// BuildKit 0.32.2 compares MinFreeSpace against statfs.bfree, whereas
|
|
135
|
+
// applications and kubelet need bavail. Include ext4's reserved blocks so
|
|
136
|
+
// pruning restores the required usable space instead of stopping early.
|
|
137
|
+
function pruneTarget(config: StickyConsumerConfig, usage: { reservedBytes?: number }): string {
|
|
138
|
+
return String(Math.ceil((config.headroomBytes + (usage.reservedBytes ?? 0)) / 1e6));
|
|
139
|
+
}
|
|
140
|
+
|
|
128
141
|
/** Builds can finish before automatic GC restores its free-space reserve.
|
|
129
142
|
* Reclaim only unused BuildKit records before project setup adds runtime data.
|
|
130
143
|
* Docker images, containers, volumes and nested Kubernetes stores stay live.
|
|
@@ -132,20 +145,17 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
|
|
|
132
145
|
export async function ensureBuildHeadroom(
|
|
133
146
|
config: StickyConsumerConfig,
|
|
134
147
|
run: typeof exec = exec,
|
|
135
|
-
|
|
136
|
-
const fs = await statfs(config.path);
|
|
137
|
-
return fs.bavail * fs.bsize;
|
|
138
|
-
},
|
|
148
|
+
usage = () => diskUsage(config.path),
|
|
139
149
|
): Promise<void> {
|
|
140
150
|
validateStickyConfig(config);
|
|
141
|
-
let
|
|
142
|
-
for (let pass = 0;
|
|
151
|
+
let fs = await usage();
|
|
152
|
+
for (let pass = 0; fs.freeBytes < config.headroomBytes && pass < 3; pass++) {
|
|
143
153
|
await run("buildctl", ["--addr", "tcp://127.0.0.1:1234", "prune", "--all", "--free-storage",
|
|
144
|
-
|
|
145
|
-
|
|
154
|
+
pruneTarget(config, fs)], 120_000);
|
|
155
|
+
fs = await usage();
|
|
146
156
|
}
|
|
147
|
-
if (
|
|
148
|
-
throw new Error(`environment build left insufficient disk headroom: ${Math.floor(
|
|
157
|
+
if (fs.freeBytes < config.headroomBytes) {
|
|
158
|
+
throw new Error(`environment build left insufficient disk headroom: ${Math.floor(fs.freeBytes / 2 ** 20)} MiB available, ${Math.ceil(config.headroomBytes / 2 ** 20)} MiB required`);
|
|
149
159
|
}
|
|
150
160
|
}
|
|
151
161
|
|
|
@@ -165,10 +175,7 @@ export async function collectStickyCache(
|
|
|
165
175
|
config: StickyConsumerConfig,
|
|
166
176
|
retention: StickyImageRetention,
|
|
167
177
|
run: typeof exec = exec,
|
|
168
|
-
usage
|
|
169
|
-
const fs = await statfs(config.path);
|
|
170
|
-
return { freeBytes: fs.bavail * fs.bsize, totalBytes: fs.blocks * fs.bsize };
|
|
171
|
-
},
|
|
178
|
+
usage: () => Promise<{ freeBytes: number; totalBytes: number; reservedBytes?: number }> = () => diskUsage(config.path),
|
|
172
179
|
kubernetes?: KubernetesCacheMaintenance,
|
|
173
180
|
): Promise<void> {
|
|
174
181
|
validateStickyConfig(config);
|
|
@@ -196,7 +203,7 @@ export async function collectStickyCache(
|
|
|
196
203
|
// a per-consumer cap evicts useful cache even when the disk is mostly empty.
|
|
197
204
|
const pruneBuildkit = async () => {
|
|
198
205
|
await run("buildctl", [...buildctl, "--all", "--free-storage",
|
|
199
|
-
|
|
206
|
+
pruneTarget(config, fs)], 120_000);
|
|
200
207
|
fs = await usage();
|
|
201
208
|
};
|
|
202
209
|
await pruneBuildkit();
|