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 "errors"
7 "fmt"
8 "io"
9 "net/http"
10 "net/url"
11 "os"
12 "sort"
13 "strings"
14 "time"
15
16 "github.com/aws/aws-sdk-go-v2/aws"
17 awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
18 "github.com/aws/smithy-go"
19 "github.com/aws/smithy-go/encoding/httpbinding"
20 smithyhttp "github.com/aws/smithy-go/transport/http"
21)
22
23// ObjectVersion is one entry of a versioned listing: an object version, or
24// a delete marker (DeleteMarker true, no ETag or Size).
25type ObjectVersion struct {
26 Key string
27 VersionID string
28 ETag string // unquoted
29 Size int64
30 IsLatest bool
31 DeleteMarker bool
32 LastModified time.Time
33}
34
35var (
36 ErrExists = errors.New("lease: object already exists")
37 ErrDestinationExists = errors.New("lease: copy destination already exists")
38 ErrIsDeleteMarker = errors.New("lease: version is a delete marker")
39 ErrNoVersionID = errors.New("lease: a version id is required")
40)
41
42// VersionedStore is the data bucket addressed by exact object version (host
43// lifecycle claim spec C3, §6.2, §6.5). No method deletes or overwrites by
44// bare key.
45type VersionedStore interface {
46 // ListVersions returns every version and delete marker under prefix:
47 // key order; within a key the IsLatest entry first, the rest newest
48 // first with ties unordered. Any page error fails the whole call.
49 ListVersions(ctx context.Context, bucket, prefix string) ([]ObjectVersion, error)
50 // OpenVersion streams one exact version. Missing: ErrMirrorNotFound.
51 // A delete marker: ErrIsDeleteMarker.
52 OpenVersion(ctx context.Context, bucket, key, versionID string) (io.ReadCloser, error)
53 // GetVersion reads one exact version whole (OpenVersion's errors).
54 GetVersion(ctx context.Context, bucket, key, versionID string) ([]byte, error)
55 // CopyVersion copies one exact source version to dstKey. createOnly
56 // adds If-None-Match: *; an existing current destination object gives
57 // ErrDestinationExists. It returns the destination's new version id
58 // and ETag.
59 CopyVersion(ctx context.Context, srcBucket, srcKey, srcVersionID, dstBucket, dstKey string, createOnly bool) (dstVersionID, dstETag string, err error)
60 // DeleteVersion removes one exact version; gone already is success.
61 // An empty versionID is ErrNoVersionID, without any request.
62 DeleteVersion(ctx context.Context, bucket, key, versionID string) error
63 // PutIfAbsent writes body only if key holds no current object version.
64 PutIfAbsent(ctx context.Context, bucket, key string, body []byte) (versionID string, err error)
65}
66
67// VersionRangeOpener optionally streams a range of one exact object version.
68// A negative length reads to the end; a positive length bounds the read.
69// Zero length and negative offsets are invalid. Errors match OpenVersion.
70type VersionRangeOpener interface {
71 OpenVersionRange(ctx context.Context, bucket, key, versionID string, offset, length int64) (io.ReadCloser, error)
72}
73
74// versionedS3 binds every bucket to its own regional client explicitly.
75// An unbound bucket is an error, never a guess.
76type versionedS3 struct {
77 clients map[string]*awss3.Client // bucket -> client for its region
78}
79
80// NewVersionedS3 builds one client per region from the default credential
81// chain (with s3compat options, as newLeaserClient does) and binds each
82// bucket to its region's client.
83func NewVersionedS3(ctx context.Context, regionByBucket map[string]string) (VersionedStore, error) {
84 byRegion := map[string]*awss3.Client{}
85 out := &versionedS3{clients: map[string]*awss3.Client{}}
86 for bucket, region := range regionByBucket {
87 c, ok := byRegion[region]
88 if !ok {
89 var err error
90 if c, _, err = newLeaserClient(ctx, region); err != nil {
91 return nil, fmt.Errorf("lease: versioned client for %s: %w", region, err)
92 }
93 byRegion[region] = c
94 }
95 out.clients[bucket] = c
96 }
97 return out, nil
98}
99
100func (s *versionedS3) client(bucket string) (*awss3.Client, error) {
101 c, ok := s.clients[bucket]
102 if !ok {
103 return nil, fmt.Errorf("lease: bucket %q is not bound to a region", bucket)
104 }
105 return c, nil
106}
107
108func (s *versionedS3) ListVersions(ctx context.Context, bucket, prefix string) ([]ObjectVersion, error) {
109 c, err := s.client(bucket)
110 if err != nil {
111 return nil, err
112 }
113 p := awss3.NewListObjectVersionsPaginator(c, &awss3.ListObjectVersionsInput{
114 Bucket: aws.String(bucket), Prefix: aws.String(prefix),
115 }) // StopOnDuplicateToken stays off: a key legitimately repeats across version pages
116 var out []ObjectVersion
117 for p.HasMorePages() {
118 page, err := p.NextPage(ctx)
119 if err != nil {
120 return nil, fmt.Errorf("lease: list versions %s/%s: %w", bucket, prefix, err)
121 }
122 for _, v := range page.Versions {
123 out = append(out, ObjectVersion{Key: aws.ToString(v.Key), VersionID: aws.ToString(v.VersionId),
124 ETag: strings.Trim(aws.ToString(v.ETag), `"`), Size: aws.ToInt64(v.Size),
125 IsLatest: aws.ToBool(v.IsLatest), LastModified: aws.ToTime(v.LastModified)})
126 }
127 for _, d := range page.DeleteMarkers {
128 out = append(out, ObjectVersion{Key: aws.ToString(d.Key), VersionID: aws.ToString(d.VersionId),
129 IsLatest: aws.ToBool(d.IsLatest), DeleteMarker: true, LastModified: aws.ToTime(d.LastModified)})
130 }
131 }
132 sort.SliceStable(out, func(i, j int) bool {
133 if out[i].Key != out[j].Key {
134 return out[i].Key < out[j].Key
135 }
136 if out[i].IsLatest != out[j].IsLatest {
137 return out[i].IsLatest
138 }
139 return out[i].LastModified.After(out[j].LastModified) // ties: unspecified (Global Constraints)
140 })
141 return out, nil
142}
143
144// apiCode returns the smithy error code and HTTP status of err.
145func apiCode(err error) (string, int) {
146 var ae smithy.APIError
147 code := ""
148 if errors.As(err, &ae) {
149 code = ae.ErrorCode()
150 }
151 var re *smithyhttp.ResponseError
152 status := 0
153 if errors.As(err, &re) {
154 status = re.HTTPStatusCode()
155 }
156 return code, status
157}
158
159// mapReadErr normalizes only object or version absence. A missing bucket
160// (NoSuchBucket, also a 404) stays an error: it is never "already gone".
161func mapReadErr(err error) error {
162 code, status := apiCode(err)
163 switch {
164 case code == "NoSuchBucket":
165 return err
166 case code == "NoSuchKey" || code == "NoSuchVersion" || code == "NotFound" || status == http.StatusNotFound:
167 return ErrMirrorNotFound
168 case status == http.StatusMethodNotAllowed:
169 return ErrIsDeleteMarker // S3 answers 405 for a delete marker's version id
170 }
171 return err
172}
173
174func (s *versionedS3) OpenVersion(ctx context.Context, bucket, key, versionID string) (io.ReadCloser, error) {
175 return s.openVersion(ctx, bucket, key, versionID, nil)
176}
177
178func (s *versionedS3) OpenVersionRange(ctx context.Context, bucket, key, versionID string, offset, length int64) (io.ReadCloser, error) {
179 if versionID == "" {
180 return nil, ErrNoVersionID
181 }
182 if offset < 0 || length == 0 || (length > 0 && offset > (1<<63-1)-(length-1)) {
183 return nil, errors.New("lease: invalid version range")
184 }
185 rangeHeader := fmt.Sprintf("bytes=%d-", offset)
186 if length > 0 {
187 rangeHeader += fmt.Sprint(offset + length - 1)
188 }
189 return s.openVersion(ctx, bucket, key, versionID, &rangeHeader)
190}
191
192func (s *versionedS3) openVersion(ctx context.Context, bucket, key, versionID string, byteRange *string) (io.ReadCloser, error) {
193 if versionID == "" {
194 return nil, ErrNoVersionID
195 }
196 c, err := s.client(bucket)
197 if err != nil {
198 return nil, err
199 }
200 out, err := c.GetObject(ctx, &awss3.GetObjectInput{Bucket: aws.String(bucket), Key: aws.String(key), VersionId: aws.String(versionID), Range: byteRange})
201 if err != nil {
202 if m := mapReadErr(err); m != err {
203 return nil, m
204 }
205 return nil, fmt.Errorf("lease: get %s/%s@%s: %w", bucket, key, versionID, err)
206 }
207 return out.Body, nil
208}
209
210func (s *versionedS3) GetVersion(ctx context.Context, bucket, key, versionID string) ([]byte, error) {
211 rc, err := s.OpenVersion(ctx, bucket, key, versionID)
212 if err != nil {
213 return nil, err
214 }
215 defer rc.Close()
216 return io.ReadAll(rc)
217}
218
219func (s *versionedS3) CopyVersion(ctx context.Context, srcBucket, srcKey, srcVersionID, dstBucket, dstKey string, createOnly bool) (string, string, error) {
220 if srcVersionID == "" {
221 return "", "", ErrNoVersionID
222 }
223 if _, err := s.client(srcBucket); err != nil {
224 return "", "", err
225 }
226 c, err := s.client(dstBucket) // a copy executes in the destination's region
227 if err != nil {
228 return "", "", err
229 }
230 in := &awss3.CopyObjectInput{
231 Bucket: aws.String(dstBucket),
232 Key: aws.String(dstKey),
233 CopySource: aws.String(httpbinding.EscapePath(srcBucket+"/"+srcKey, false) + "?versionId=" + url.QueryEscape(srcVersionID)),
234 }
235 if createOnly {
236 in.IfNoneMatch = aws.String("*")
237 }
238 out, err := c.CopyObject(ctx, in)
239 if err != nil {
240 code, status := apiCode(err)
241 var ae smithy.APIError
242 switch {
243 case createOnly && (code == "PreconditionFailed" || status == http.StatusPreconditionFailed):
244 return "", "", ErrDestinationExists
245 case code == "InvalidRequest" && errors.As(err, &ae) && strings.Contains(strings.ToLower(ae.ErrorMessage()), "delete marker"):
246 return "", "", ErrIsDeleteMarker
247 default:
248 if m := mapReadErr(err); m != err {
249 return "", "", m
250 }
251 }
252 return "", "", fmt.Errorf("lease: copy %s/%s@%s -> %s/%s: %w", srcBucket, srcKey, srcVersionID, dstBucket, dstKey, err)
253 }
254 etag := ""
255 if out.CopyObjectResult != nil {
256 etag = strings.Trim(aws.ToString(out.CopyObjectResult.ETag), `"`)
257 }
258 return aws.ToString(out.VersionId), etag, nil
259}
260
261func (s *versionedS3) DeleteVersion(ctx context.Context, bucket, key, versionID string) error {
262 if versionID == "" {
263 return ErrNoVersionID // never a bare-key delete (spec C3), and no request is sent
264 }
265 c, err := s.client(bucket)
266 if err != nil {
267 return err
268 }
269 if _, err := c.DeleteObject(ctx, &awss3.DeleteObjectInput{Bucket: aws.String(bucket), Key: aws.String(key), VersionId: aws.String(versionID)}); err != nil {
270 if mapReadErr(err) == ErrMirrorNotFound {
271 return nil // already gone
272 }
273 return fmt.Errorf("lease: delete %s/%s@%s: %w", bucket, key, versionID, err)
274 }
275 return nil
276}
277
278func (s *versionedS3) PutIfAbsent(ctx context.Context, bucket, key string, body []byte) (string, error) {
279 c, err := s.client(bucket)
280 if err != nil {
281 return "", err
282 }
283 out, err := c.PutObject(ctx, &awss3.PutObjectInput{Bucket: aws.String(bucket), Key: aws.String(key),
284 Body: bytes.NewReader(body), IfNoneMatch: aws.String("*")})
285 if err != nil {
286 if code, status := apiCode(err); code == "PreconditionFailed" || status == http.StatusPreconditionFailed {
287 return "", ErrExists
288 }
289 return "", fmt.Errorf("lease: put-if-absent %s/%s: %w", bucket, key, err)
290 }
291 return aws.ToString(out.VersionId), nil
292}
293
294// FilePutIfAbsent is PutIfAbsent streaming a file from disk, for objects
295// too large to hold in memory: a move's sealed archive, written create-only
296// into the source's own move-snapshots/ (moves spec §8.3). The S3 store
297// implements it; an existing current object is ErrExists.
298type FilePutIfAbsent interface {
299 PutFileIfAbsent(ctx context.Context, bucket, key, path string) (versionID string, err error)
300}
301
302func (s *versionedS3) PutFileIfAbsent(ctx context.Context, bucket, key, path string) (string, error) {
303 c, err := s.client(bucket)
304 if err != nil {
305 return "", err
306 }
307 f, err := os.Open(path)
308 if err != nil {
309 return "", err
310 }
311 defer f.Close()
312 st, err := f.Stat()
313 if err != nil {
314 return "", err
315 }
316 // An *os.File is seekable, so the SDK hashes the payload by reading it
317 // and seeking back, never by buffering it.
318 out, err := c.PutObject(ctx, &awss3.PutObjectInput{Bucket: aws.String(bucket), Key: aws.String(key),
319 Body: f, ContentLength: aws.Int64(st.Size()), IfNoneMatch: aws.String("*")})
320 if err != nil {
321 if code, status := apiCode(err); code == "PreconditionFailed" || status == http.StatusPreconditionFailed {
322 return "", ErrExists
323 }
324 return "", fmt.Errorf("lease: put-if-absent %s/%s: %w", bucket, key, err)
325 }
326 return aws.ToString(out.VersionId), nil
327}
328
329var _ VersionedStore = (*versionedS3)(nil)
330
331var _ FilePutIfAbsent = (*versionedS3)(nil)
332
333var _ VersionRangeOpener = (*versionedS3)(nil)
334