carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package lease
2
3import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/xml"
9 "errors"
10 "fmt"
11 "io"
12 "net/http"
13 "net/url"
14 "strings"
15 "time"
16
17 "github.com/aws/aws-sdk-go-v2/aws"
18 v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
19 awsconfig "github.com/aws/aws-sdk-go-v2/config"
20 awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
21 "github.com/carlosframework/platform/internal/rootaws"
22)
23
24// The staging mirror (moves spec §7.3, model 14): the writer of a move's
25// staging area, whose every mutation is signed at a timestamp the caller
26// passes and sent exactly once.
27//
28// Why it signs by hand. A SigV4 signature stays valid for 15 minutes, and
29// the SDK signs when its middleware runs, after any retry, backoff or
30// pause the caller cannot see. The moves guard (moves.Guarded) proves the
31// staging lease is safe for a signature's whole life at one instant and
32// hands that instant to the mutation as signAt. This type signs AT signAt
33// and reads no clock of its own, so a pause of any length between the
34// check and the send (a frozen VM, a stalled goroutine) yields a request
35// dated at the check, which S3 refuses once that date is 15 minutes old:
36// exactly the life the guard required of the lease. There is no retry and
37// no re-signing; a caller that wants another attempt goes back through
38// the guard and gets a new timestamp. The one gap left is between the
39// signing and the socket write, bounded by the request timeout and the
40// guard's skew allowance (§7.3).
41//
42// *StagingMirror deliberately satisfies none of MirrorStore's mutations,
43// VersionedCopier or VersionedPutter: each of those lacks a signAt, and
44// only the guard hands one out, so no code path can sign outside a
45// check. Callers hold it as a StagingWriter behind a guard adapter.
46
47// StagingWriter is what the move lane needs of its staging store: the
48// four guarded mutations and the two reads. *StagingMirror is the
49// production implementation; the lane's tests supply a fake.
50type StagingWriter interface {
51 // CopyVersionedAt is a server-side copy into the staging bucket,
52 // conditional on srcETag when it is set (ErrCopySourceChanged when the
53 // source no longer carries it). It returns the destination's ETag
54 // (unquoted) and version id.
55 CopyVersionedAt(ctx context.Context, signAt time.Time, srcBucket, srcKey, srcETag, dstBucket, dstKey string) (etag, versionID string, err error)
56 // PutVersionedAt writes body over key and returns the new version id.
57 PutVersionedAt(ctx context.Context, signAt time.Time, bucket, key string, body []byte) (versionID string, err error)
58 // PutReaderVersionedAt is PutVersionedAt streaming size bytes from r,
59 // signed with the sha256 the caller computed while writing them.
60 PutReaderVersionedAt(ctx context.Context, signAt time.Time, bucket, key string, r io.Reader, size int64, sha256Hex string) (versionID string, err error)
61 // PutIfAbsentVersionedAt is PutVersionedAt with If-None-Match: *; an
62 // existing object gives ErrExists.
63 PutIfAbsentVersionedAt(ctx context.Context, signAt time.Time, bucket, key string, body []byte) (versionID string, err error)
64 // DeleteAt removes key (on a versioned bucket, adds a delete marker).
65 DeleteAt(ctx context.Context, signAt time.Time, bucket, key string) error
66 List(ctx context.Context, bucket, prefix string) (map[string]MirrorObject, error)
67 Get(ctx context.Context, bucket, key string) ([]byte, error)
68}
69
70var _ StagingWriter = (*StagingMirror)(nil)
71
72const (
73 stagingRequestTimeout = 120 * time.Second
74 unsignedPayload = "UNSIGNED-PAYLOAD"
75 // stagingMaxResponse caps what is read of any response: the bodies
76 // that matter (a CopyObjectResult, an error document) are tiny.
77 stagingMaxResponse = 1 << 20
78)
79
80// StagingMirror signs and sends staging mutations itself. See the package
81// comment above. Build it with NewStagingMirror (production) or
82// NewStagingMirrorAt (tests, an endpoint override).
83type StagingMirror struct {
84 reads MirrorStore // List and Get: ordinary reads, no guard needed
85 endpoint string // base URL override (path-style); "" is AWS
86 region string // the destination bucket's region: where requests go and what they are signed for
87 creds aws.CredentialsProvider
88 signer *v4.Signer
89 client *http.Client
90}
91
92// newStagingClient never follows a redirect: a 307/308 would resend the
93// mutation and a 301/302/303 would turn it into a GET, and a mutation is
94// sent once. The 3xx answer comes back as the response and is an error.
95func newStagingClient() *http.Client {
96 return &http.Client{
97 Timeout: stagingRequestTimeout,
98 CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
99 }
100}
101
102// versionIDOf is the version id a 2xx answer carries. A staging bucket
103// must be versioned, since every artifact is sealed by version (§7.3), so
104// an answer without one, or with the suspended bucket's "null"
105// (UsableVersionID), fails closed: the write may have landed, and the
106// caller learns it cannot be sealed.
107func versionIDOf(op string, h http.Header) (string, error) {
108 v := h.Get("X-Amz-Version-Id")
109 if !UsableVersionID(v) {
110 return "", fmt.Errorf("staging mirror: %s: no usable version id in the response (%q; the staging bucket must be versioned)", op, v)
111 }
112 return v, nil
113}
114
115func newStagingSigner() *v4.Signer {
116 // S3 canonicalizes the path as sent, never double-escaped.
117 return v4.NewSigner(func(o *v4.SignerOptions) { o.DisableURIPathEscaping = true })
118}
119
120// NewStagingMirror builds the production mirror: reads through the same
121// two-region pair NewS3Mirror uses, mutations signed with the default
122// credential chain for dstRegion (the staging bucket's region).
123// CARLOS_S3_ENDPOINT, when set, replaces the AWS endpoint, path-style.
124func NewStagingMirror(ctx context.Context, srcRegion, dstRegion string) (*StagingMirror, error) {
125 reads, err := NewS3Mirror(ctx, srcRegion, dstRegion)
126 if err != nil {
127 return nil, err
128 }
129 var opts []func(*awsconfig.LoadOptions) error
130 if dstRegion != "" {
131 opts = append(opts, awsconfig.WithRegion(dstRegion))
132 }
133 cfg, err := rootaws.LoadDefaultConfig(ctx, opts...)
134 if err != nil {
135 return nil, fmt.Errorf("load aws config: %w", err)
136 }
137 if cfg.Credentials == nil {
138 return nil, errors.New("staging mirror: no AWS credentials to sign with")
139 }
140 return &StagingMirror{
141 reads: reads,
142 endpoint: s3Endpoint(),
143 region: regionFor(dstRegion, cfg.Region),
144 creds: cfg.Credentials,
145 signer: newStagingSigner(),
146 client: newStagingClient(),
147 }, nil
148}
149
150// NewStagingMirrorAt is the constructor with the endpoint and credentials
151// given, path-style, for tests against a local S3 (leasetest.NewS3Server).
152// Reads go to the same endpoint.
153func NewStagingMirrorAt(endpoint, region string, creds aws.CredentialsProvider) *StagingMirror {
154 if !strings.Contains(endpoint, "://") {
155 endpoint = "https://" + endpoint
156 }
157 c := awss3.New(awss3.Options{
158 Region: region,
159 Credentials: creds,
160 BaseEndpoint: &endpoint,
161 UsePathStyle: true,
162 RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired,
163 ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired,
164 })
165 return &StagingMirror{
166 reads: &s3Mirror{src: c, dst: c, byBucket: map[string]*awss3.Client{}},
167 endpoint: endpoint,
168 region: region,
169 creds: creds,
170 signer: newStagingSigner(),
171 client: newStagingClient(),
172 }
173}
174
175func (m *StagingMirror) List(ctx context.Context, bucket, prefix string) (map[string]MirrorObject, error) {
176 return m.reads.List(ctx, bucket, prefix)
177}
178
179func (m *StagingMirror) Get(ctx context.Context, bucket, key string) ([]byte, error) {
180 return m.reads.Get(ctx, bucket, key)
181}
182
183// s3Escape percent-encodes everything but the RFC 3986 unreserved set and
184// "/", which is what S3 canonicalizes object paths (and the copy-source
185// header) to.
186func s3Escape(s string) string {
187 const hexdigits = "0123456789ABCDEF"
188 var b strings.Builder
189 for i := 0; i < len(s); i++ {
190 c := s[i]
191 switch {
192 case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9',
193 c == '-', c == '_', c == '.', c == '~', c == '/':
194 b.WriteByte(c)
195 default:
196 b.WriteByte('%')
197 b.WriteByte(hexdigits[c>>4])
198 b.WriteByte(hexdigits[c&15])
199 }
200 }
201 return b.String()
202}
203
204// objectURL addresses bucket/key: the override path-style, else AWS
205// virtual-hosted (path-style for a dotted bucket name, whose name the
206// wildcard certificate does not cover).
207func (m *StagingMirror) objectURL(bucket, key string) (*url.URL, error) {
208 var u *url.URL
209 var path string
210 switch {
211 case m.endpoint != "":
212 base, err := url.Parse(m.endpoint)
213 if err != nil {
214 return nil, fmt.Errorf("staging mirror: endpoint %q: %w", m.endpoint, err)
215 }
216 u, path = base, strings.TrimSuffix(base.Path, "/")+"/"+bucket+"/"+key
217 case strings.Contains(bucket, "."):
218 u, path = &url.URL{Scheme: "https", Host: "s3." + m.region + ".amazonaws.com"}, "/"+bucket+"/"+key
219 default:
220 u, path = &url.URL{Scheme: "https", Host: bucket + ".s3." + m.region + ".amazonaws.com"}, "/"+key
221 }
222 raw := s3Escape(path)
223 dec, err := url.PathUnescape(raw)
224 if err != nil {
225 return nil, err
226 }
227 out := *u
228 out.Path, out.RawPath, out.RawQuery = dec, raw, ""
229 return &out, nil
230}
231
232// send signs one request at signAt and sends it once. Nothing here reads a
233// clock, retries, or re-signs. It returns the status, headers and (capped)
234// body of whatever S3 answered.
235func (m *StagingMirror) send(ctx context.Context, signAt time.Time, method, bucket, key string, hdr http.Header, body []byte) (int, http.Header, []byte, error) {
236 if body == nil {
237 return m.sendBody(ctx, signAt, method, bucket, key, hdr, nil, 0, unsignedPayload)
238 }
239 sum := sha256.Sum256(body)
240 return m.sendBody(ctx, signAt, method, bucket, key, hdr, bytes.NewReader(body), int64(len(body)), hex.EncodeToString(sum[:]))
241}
242
243// sendBody is send with the body as a reader of a declared size whose
244// sha256 (hex) the caller already knows: it is the signed payload hash, so
245// S3 refuses a body that does not match it, and a reader that ends short of
246// size fails the request rather than landing a truncated object.
247func (m *StagingMirror) sendBody(ctx context.Context, signAt time.Time, method, bucket, key string, hdr http.Header, rd io.Reader, size int64, payloadHash string) (int, http.Header, []byte, error) {
248 if signAt.IsZero() {
249 return 0, nil, nil, errors.New("staging mirror: no signing time (a mutation signs at a Guarded.Before check)")
250 }
251 u, err := m.objectURL(bucket, key)
252 if err != nil {
253 return 0, nil, nil, err
254 }
255 req, err := http.NewRequestWithContext(ctx, method, u.String(), rd)
256 if err != nil {
257 return 0, nil, nil, err
258 }
259 if rd != nil {
260 req.ContentLength = size
261 }
262 for k, vs := range hdr {
263 req.Header[k] = vs
264 }
265 req.Header.Set("X-Amz-Content-Sha256", payloadHash)
266 creds, err := m.creds.Retrieve(ctx)
267 if err != nil {
268 return 0, nil, nil, fmt.Errorf("staging mirror: credentials: %w", err)
269 }
270 if err := m.signer.SignHTTP(ctx, creds, req, payloadHash, "s3", m.region, signAt); err != nil {
271 return 0, nil, nil, fmt.Errorf("staging mirror: sign: %w", err)
272 }
273 resp, err := m.client.Do(req)
274 if err != nil {
275 return 0, nil, nil, fmt.Errorf("staging mirror: %s %s/%s: %w", method, bucket, key, err)
276 }
277 defer resp.Body.Close()
278 rb, err := io.ReadAll(io.LimitReader(resp.Body, stagingMaxResponse))
279 if err != nil {
280 return resp.StatusCode, resp.Header, nil, fmt.Errorf("staging mirror: %s %s/%s: read response: %w", method, bucket, key, err)
281 }
282 return resp.StatusCode, resp.Header, rb, nil
283}
284
285// s3ErrorBody is the document S3 puts in an error response, and in the
286// body of a 200 copy that failed after the headers went out.
287type s3ErrorBody struct {
288 XMLName xml.Name `xml:"Error"`
289 Code string `xml:"Code"`
290 Message string `xml:"Message"`
291}
292
293// s3Code is the error document's Code, or "" if the body is not one.
294func s3Code(body []byte) string {
295 var e s3ErrorBody
296 if xml.Unmarshal(body, &e) != nil {
297 return ""
298 }
299 return e.Code
300}
301
302func stagingFailure(op string, status int, body []byte) error {
303 code := s3Code(body)
304 if code == "" {
305 code = http.StatusText(status)
306 }
307 return fmt.Errorf("staging mirror: %s: %s (HTTP %d)", op, code, status)
308}
309
310func (m *StagingMirror) CopyVersionedAt(ctx context.Context, signAt time.Time, srcBucket, srcKey, srcETag, dstBucket, dstKey string) (string, string, error) {
311 hdr := http.Header{}
312 hdr.Set("X-Amz-Copy-Source", "/"+srcBucket+"/"+s3Escape(srcKey))
313 if srcETag != "" {
314 hdr.Set("X-Amz-Copy-Source-If-Match", `"`+normalizeETag(srcETag)+`"`)
315 }
316 op := "copy " + srcBucket + "/" + srcKey
317 status, h, body, err := m.send(ctx, signAt, http.MethodPut, dstBucket, dstKey, hdr, nil)
318 if err != nil {
319 return "", "", err
320 }
321 if status == http.StatusPreconditionFailed && srcETag != "" && s3Code(body) == "PreconditionFailed" {
322 return "", "", ErrCopySourceChanged
323 }
324 if status/100 != 2 {
325 return "", "", stagingFailure(op, status, body)
326 }
327 // S3 may answer 200 and then fail the copy, putting an <Error> document
328 // where the result belongs.
329 if code := s3Code(body); code != "" {
330 return "", "", fmt.Errorf("staging mirror: %s: %s (HTTP %d, late failure)", op, code, status)
331 }
332 var res struct {
333 XMLName xml.Name `xml:"CopyObjectResult"`
334 ETag string `xml:"ETag"`
335 }
336 if err := xml.Unmarshal(body, &res); err != nil || res.ETag == "" {
337 return "", "", fmt.Errorf("staging mirror: %s: no CopyObjectResult in a 200 response", op)
338 }
339 ver, err := versionIDOf(op, h)
340 if err != nil {
341 return "", "", err
342 }
343 return normalizeETag(res.ETag), ver, nil
344}
345
346func (m *StagingMirror) put(ctx context.Context, signAt time.Time, bucket, key string, body []byte, createOnly bool) (string, error) {
347 if body == nil {
348 body = []byte{}
349 }
350 hdr := http.Header{}
351 hdr.Set("Content-Type", "application/json")
352 if createOnly {
353 hdr.Set("If-None-Match", "*")
354 }
355 status, h, rb, err := m.send(ctx, signAt, http.MethodPut, bucket, key, hdr, body)
356 if err != nil {
357 return "", err
358 }
359 if createOnly && status == http.StatusPreconditionFailed {
360 return "", ErrExists
361 }
362 if status/100 != 2 {
363 return "", stagingFailure("put "+bucket+"/"+key, status, rb)
364 }
365 return versionIDOf("put "+bucket+"/"+key, h)
366}
367
368func (m *StagingMirror) PutVersionedAt(ctx context.Context, signAt time.Time, bucket, key string, body []byte) (string, error) {
369 return m.put(ctx, signAt, bucket, key, body, false)
370}
371
372// PutReaderVersionedAt is PutVersionedAt for a body read from r: size bytes
373// whose sha256 is sha256Hex. The archive is staged this way from its file,
374// so a 256 MiB archive is never held in memory.
375func (m *StagingMirror) PutReaderVersionedAt(ctx context.Context, signAt time.Time, bucket, key string, r io.Reader, size int64, sha256Hex string) (string, error) {
376 if size < 0 {
377 return "", fmt.Errorf("staging mirror: put %s/%s: negative size", bucket, key)
378 }
379 if b, err := hex.DecodeString(sha256Hex); err != nil || len(b) != sha256.Size {
380 return "", fmt.Errorf("staging mirror: put %s/%s: not a sha256", bucket, key)
381 }
382 hdr := http.Header{}
383 hdr.Set("Content-Type", "application/octet-stream")
384 status, h, rb, err := m.sendBody(ctx, signAt, http.MethodPut, bucket, key, hdr, io.LimitReader(r, size), size, sha256Hex)
385 if err != nil {
386 return "", err
387 }
388 if status/100 != 2 {
389 return "", stagingFailure("put "+bucket+"/"+key, status, rb)
390 }
391 return versionIDOf("put "+bucket+"/"+key, h)
392}
393
394func (m *StagingMirror) PutIfAbsentVersionedAt(ctx context.Context, signAt time.Time, bucket, key string, body []byte) (string, error) {
395 return m.put(ctx, signAt, bucket, key, body, true)
396}
397
398func (m *StagingMirror) DeleteAt(ctx context.Context, signAt time.Time, bucket, key string) error {
399 status, _, body, err := m.send(ctx, signAt, http.MethodDelete, bucket, key, nil, nil)
400 if err != nil {
401 return err
402 }
403 if status/100 != 2 {
404 return stagingFailure("delete "+bucket+"/"+key, status, body)
405 }
406 return nil
407}
408
409// UsableVersionID reports whether v names one immutable object version. ""
410// is no version at all. "null" is what a versioning-suspended (or never
411// versioned) bucket answers: the next write to the key overwrites that very
412// version, so it pins nothing and a seal by it is no seal (final review
413// finding 4; AWS, "Adding objects to versioning-suspended buckets").
414func UsableVersionID(v string) bool { return v != "" && v != "null" }
415