feat(blobs): blob store port with disk and provider-agnostic S3 adapters
- commit
- 8fd2b19ab02372a07e8e1bffb784717dde79a0e3
- author
- brice · · 2026-10-21 14:22 UTC
- committer
- brice · · 2026-10-21 17:53 UTC
- parent
- c57f9ec
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +2342 -0
M go.mod +17 -0
| @@ -20,11 +20,15 @@ require ( | |||
| 20 | 20 | github.com/ProtonMail/go-crypto v1.4.1 | |
| 21 | 21 | github.com/a-h/templ v0.3.1020 | |
| 22 | 22 | github.com/alecthomas/chroma/v2 v2.27.0 | |
| 23 | + | github.com/aws/aws-sdk-go-v2 v1.47.3 | |
| 24 | + | github.com/aws/aws-sdk-go-v2/credentials v1.20.9 | |
| 25 | + | github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3 | |
| 23 | 26 | github.com/bnema/kvconf v0.1.2 | |
| 24 | 27 | github.com/bnema/zerowrap v1.4.1 | |
| 25 | 28 | github.com/descope/virtualwebauthn v1.0.5 | |
| 26 | 29 | github.com/go-webauthn/webauthn v0.18.2 | |
| 27 | 30 | github.com/golang-jwt/jwt/v5 v5.3.1 | |
| 31 | + | github.com/johannesboyne/gofakes3 v1.2.0 | |
| 28 | 32 | github.com/spf13/cobra v1.10.2 | |
| 29 | 33 | github.com/stretchr/testify v1.12.1 | |
| 30 | 34 | github.com/yuin/goldmark v1.8.6 | |
| @@ -61,6 +65,15 @@ require ( | |||
| 61 | 65 | github.com/air-verse/air v1.67.4 // indirect | |
| 62 | 66 | github.com/andybalholm/brotli v1.2.0 // indirect | |
| 63 | 67 | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect | |
| 68 | + | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 // indirect | |
| 69 | + | github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 // indirect | |
| 70 | + | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 // indirect | |
| 71 | + | github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 // indirect | |
| 72 | + | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 // indirect | |
| 73 | + | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 // indirect | |
| 74 | + | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 // indirect | |
| 75 | + | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 // indirect | |
| 76 | + | github.com/aws/smithy-go v1.28.5 // indirect | |
| 64 | 77 | github.com/bep/godartsass/v2 v2.5.0 // indirect | |
| 65 | 78 | github.com/bep/golibsass v1.2.0 // indirect | |
| 66 | 79 | github.com/bits-and-blooms/bitset v1.24.5 // indirect | |
| @@ -134,6 +147,7 @@ require ( | |||
| 134 | 147 | github.com/rs/cors v1.11.1 // indirect | |
| 135 | 148 | github.com/rs/zerolog v1.35.1 // indirect | |
| 136 | 149 | github.com/russross/blackfriday/v2 v2.1.0 // indirect | |
| 150 | + | github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect | |
| 137 | 151 | github.com/segmentio/asm v1.2.1 // indirect | |
| 138 | 152 | github.com/segmentio/encoding v0.5.4 // indirect | |
| 139 | 153 | github.com/sirupsen/logrus v1.10.2 // indirect | |
| @@ -159,6 +173,7 @@ require ( | |||
| 159 | 173 | go.opentelemetry.io/otel v1.46.0 // indirect | |
| 160 | 174 | go.opentelemetry.io/otel/metric v1.46.0 // indirect | |
| 161 | 175 | go.opentelemetry.io/otel/trace v1.46.0 // indirect | |
| 176 | + | go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect | |
| 162 | 177 | go.uber.org/multierr v1.11.0 // indirect | |
| 163 | 178 | go.uber.org/zap v1.28.0 // indirect | |
| 164 | 179 | golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect | |
| @@ -172,3 +187,5 @@ require ( | |||
| 172 | 187 | mvdan.cc/xurls/v2 v2.6.0 // indirect | |
| 173 | 188 | pluginrpc.com/pluginrpc v0.5.0 // indirect | |
| 174 | 189 | ) | |
| 190 | + | ||
| 191 | + | replace bnema.dev/ermite-api => /home/brice/dev/projects/ermite-workspace/ermite-api/.worktrees/releases | |
| @@ -20,11 +20,15 @@ require ( | |||||
| 20 | github.com/ProtonMail/go-crypto v1.4.1 | 20 | github.com/ProtonMail/go-crypto v1.4.1 | ||
| 21 | github.com/a-h/templ v0.3.1020 | 21 | github.com/a-h/templ v0.3.1020 | ||
| 22 | github.com/alecthomas/chroma/v2 v2.27.0 | 22 | github.com/alecthomas/chroma/v2 v2.27.0 | ||
| 23 | + | github.com/aws/aws-sdk-go-v2 v1.47.3 | |||
| 24 | + | github.com/aws/aws-sdk-go-v2/credentials v1.20.9 | |||
| 25 | + | github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3 | |||
| 23 | github.com/bnema/kvconf v0.1.2 | 26 | github.com/bnema/kvconf v0.1.2 | ||
| 24 | github.com/bnema/zerowrap v1.4.1 | 27 | github.com/bnema/zerowrap v1.4.1 | ||
| 25 | github.com/descope/virtualwebauthn v1.0.5 | 28 | github.com/descope/virtualwebauthn v1.0.5 | ||
| 26 | github.com/go-webauthn/webauthn v0.18.2 | 29 | github.com/go-webauthn/webauthn v0.18.2 | ||
| 27 | github.com/golang-jwt/jwt/v5 v5.3.1 | 30 | github.com/golang-jwt/jwt/v5 v5.3.1 | ||
| 31 | + | github.com/johannesboyne/gofakes3 v1.2.0 | |||
| 28 | github.com/spf13/cobra v1.10.2 | 32 | github.com/spf13/cobra v1.10.2 | ||
| 29 | github.com/stretchr/testify v1.12.1 | 33 | github.com/stretchr/testify v1.12.1 | ||
| 30 | github.com/yuin/goldmark v1.8.6 | 34 | github.com/yuin/goldmark v1.8.6 | ||
| @@ -61,6 +65,15 @@ require ( | |||||
| 61 | github.com/air-verse/air v1.67.4 // indirect | 65 | github.com/air-verse/air v1.67.4 // indirect | ||
| 62 | github.com/andybalholm/brotli v1.2.0 // indirect | 66 | github.com/andybalholm/brotli v1.2.0 // indirect | ||
| 63 | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect | 67 | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect | ||
| 68 | + | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 // indirect | |||
| 69 | + | github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 // indirect | |||
| 70 | + | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 // indirect | |||
| 71 | + | github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 // indirect | |||
| 72 | + | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 // indirect | |||
| 73 | + | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 // indirect | |||
| 74 | + | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 // indirect | |||
| 75 | + | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 // indirect | |||
| 76 | + | github.com/aws/smithy-go v1.28.5 // indirect | |||
| 64 | github.com/bep/godartsass/v2 v2.5.0 // indirect | 77 | github.com/bep/godartsass/v2 v2.5.0 // indirect | ||
| 65 | github.com/bep/golibsass v1.2.0 // indirect | 78 | github.com/bep/golibsass v1.2.0 // indirect | ||
| 66 | github.com/bits-and-blooms/bitset v1.24.5 // indirect | 79 | github.com/bits-and-blooms/bitset v1.24.5 // indirect | ||
| @@ -134,6 +147,7 @@ require ( | |||||
| 134 | github.com/rs/cors v1.11.1 // indirect | 147 | github.com/rs/cors v1.11.1 // indirect | ||
| 135 | github.com/rs/zerolog v1.35.1 // indirect | 148 | github.com/rs/zerolog v1.35.1 // indirect | ||
| 136 | github.com/russross/blackfriday/v2 v2.1.0 // indirect | 149 | github.com/russross/blackfriday/v2 v2.1.0 // indirect | ||
| 150 | + | github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect | |||
| 137 | github.com/segmentio/asm v1.2.1 // indirect | 151 | github.com/segmentio/asm v1.2.1 // indirect | ||
| 138 | github.com/segmentio/encoding v0.5.4 // indirect | 152 | github.com/segmentio/encoding v0.5.4 // indirect | ||
| 139 | github.com/sirupsen/logrus v1.10.2 // indirect | 153 | github.com/sirupsen/logrus v1.10.2 // indirect | ||
| @@ -159,6 +173,7 @@ require ( | |||||
| 159 | go.opentelemetry.io/otel v1.46.0 // indirect | 173 | go.opentelemetry.io/otel v1.46.0 // indirect | ||
| 160 | go.opentelemetry.io/otel/metric v1.46.0 // indirect | 174 | go.opentelemetry.io/otel/metric v1.46.0 // indirect | ||
| 161 | go.opentelemetry.io/otel/trace v1.46.0 // indirect | 175 | go.opentelemetry.io/otel/trace v1.46.0 // indirect | ||
| 176 | + | go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect | |||
| 162 | go.uber.org/multierr v1.11.0 // indirect | 177 | go.uber.org/multierr v1.11.0 // indirect | ||
| 163 | go.uber.org/zap v1.28.0 // indirect | 178 | go.uber.org/zap v1.28.0 // indirect | ||
| 164 | golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect | 179 | golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect | ||
| @@ -172,3 +187,5 @@ require ( | |||||
| 172 | mvdan.cc/xurls/v2 v2.6.0 // indirect | 187 | mvdan.cc/xurls/v2 v2.6.0 // indirect | ||
| 173 | pluginrpc.com/pluginrpc v0.5.0 // indirect | 188 | pluginrpc.com/pluginrpc v0.5.0 // indirect | ||
| 174 | ) | 189 | ) | ||
| 190 | + | ||||
| 191 | + | replace bnema.dev/ermite-api => /home/brice/dev/projects/ermite-workspace/ermite-api/.worktrees/releases | |||
A internal/adapters/blobdisk/blobdisk.go +456 -0
| @@ -0,0 +1,456 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package blobdisk stores objects under root/objects, temporary writes under | |
| 4 | + | // root/tmp, and unfinished multipart uploads under root/multipart. Files are | |
| 5 | + | // 0600 in 0700 directories; completed objects appear by synced atomic rename. | |
| 6 | + | package blobdisk | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "context" | |
| 10 | + | "crypto/rand" | |
| 11 | + | "crypto/sha256" | |
| 12 | + | "encoding/hex" | |
| 13 | + | "encoding/json" | |
| 14 | + | "errors" | |
| 15 | + | "fmt" | |
| 16 | + | "io" | |
| 17 | + | "io/fs" | |
| 18 | + | "os" | |
| 19 | + | "path/filepath" | |
| 20 | + | "sort" | |
| 21 | + | "strconv" | |
| 22 | + | "strings" | |
| 23 | + | "time" | |
| 24 | + | ||
| 25 | + | "bnema.dev/ermite/internal/domain" | |
| 26 | + | "bnema.dev/ermite/internal/ports" | |
| 27 | + | "github.com/bnema/zerowrap" | |
| 28 | + | ) | |
| 29 | + | ||
| 30 | + | // Store is the disk implementation of ports.BlobStore. | |
| 31 | + | type Store struct{ root string } | |
| 32 | + | ||
| 33 | + | var _ ports.BlobStore = (*Store)(nil) | |
| 34 | + | ||
| 35 | + | // Dir returns the blob root under dataDir. | |
| 36 | + | func Dir(dataDir string) string { return filepath.Join(dataDir, "blobs") } | |
| 37 | + | ||
| 38 | + | // New returns a disk store without performing I/O. | |
| 39 | + | func New(root string) *Store { return &Store{root: root} } | |
| 40 | + | func (s *Store) object(key string) string { | |
| 41 | + | return filepath.Join(s.root, "objects", filepath.FromSlash(key)) | |
| 42 | + | } | |
| 43 | + | func valid(key string) error { | |
| 44 | + | if !ports.ValidBlobKey(key) { | |
| 45 | + | return domain.ErrInvalid | |
| 46 | + | } | |
| 47 | + | return nil | |
| 48 | + | } | |
| 49 | + | func translate(err error) error { | |
| 50 | + | if errors.Is(err, fs.ErrNotExist) { | |
| 51 | + | return domain.ErrNotFound | |
| 52 | + | } | |
| 53 | + | return err | |
| 54 | + | } | |
| 55 | + | func syncDir(path string) error { | |
| 56 | + | f, e := os.Open(path) | |
| 57 | + | if e != nil { | |
| 58 | + | return e | |
| 59 | + | } | |
| 60 | + | return errors.Join(f.Sync(), f.Close()) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | type ctxReader struct { | |
| 64 | + | ctx context.Context | |
| 65 | + | r io.Reader | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (r ctxReader) Read(p []byte) (int, error) { | |
| 69 | + | if e := r.ctx.Err(); e != nil { | |
| 70 | + | return 0, e | |
| 71 | + | } | |
| 72 | + | return r.r.Read(p) | |
| 73 | + | } | |
| 74 | + | func (s *Store) init(ctx context.Context) error { | |
| 75 | + | if e := ctx.Err(); e != nil { | |
| 76 | + | return e | |
| 77 | + | } | |
| 78 | + | for _, d := range []string{"objects", "tmp", "multipart"} { | |
| 79 | + | if e := os.MkdirAll(filepath.Join(s.root, d), 0700); e != nil { | |
| 80 | + | return e | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | return nil | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // Check verifies that the root is writable. | |
| 87 | + | func (s *Store) Check(ctx context.Context) error { | |
| 88 | + | logEntry := zerowrap.FromCtx(ctx) | |
| 89 | + | logEntry.Debug().Msg("blobdisk: Check") | |
| 90 | + | if e := s.init(ctx); e != nil { | |
| 91 | + | return e | |
| 92 | + | } | |
| 93 | + | f, e := os.CreateTemp(filepath.Join(s.root, "tmp"), ".probe-*") | |
| 94 | + | if e != nil { | |
| 95 | + | return e | |
| 96 | + | } | |
| 97 | + | return errors.Join(f.Close(), os.Remove(f.Name())) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // Put atomically replaces key with exactly size bytes. | |
| 101 | + | func (s *Store) Put(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) { | |
| 102 | + | logEntry := zerowrap.FromCtx(ctx) | |
| 103 | + | logEntry.Debug().Msg("blobdisk: Put") | |
| 104 | + | if e := valid(key); e != nil { | |
| 105 | + | return ports.BlobInfo{}, e | |
| 106 | + | } | |
| 107 | + | if size < 0 { | |
| 108 | + | return ports.BlobInfo{}, domain.ErrInvalid | |
| 109 | + | } | |
| 110 | + | if e := s.init(ctx); e != nil { | |
| 111 | + | return ports.BlobInfo{}, e | |
| 112 | + | } | |
| 113 | + | f, e := os.CreateTemp(filepath.Join(s.root, "tmp"), ".tmp-*") | |
| 114 | + | if e != nil { | |
| 115 | + | return ports.BlobInfo{}, e | |
| 116 | + | } | |
| 117 | + | defer os.Remove(f.Name()) | |
| 118 | + | h := sha256.New() | |
| 119 | + | n, e := io.Copy(io.MultiWriter(f, h), ctxReader{ctx, r}) | |
| 120 | + | if e == nil && n != size { | |
| 121 | + | e = domain.ErrInvalid | |
| 122 | + | } | |
| 123 | + | if e == nil { | |
| 124 | + | e = ctx.Err() | |
| 125 | + | } | |
| 126 | + | if e == nil { | |
| 127 | + | e = f.Sync() | |
| 128 | + | } | |
| 129 | + | e = errors.Join(e, f.Close()) | |
| 130 | + | if e != nil { | |
| 131 | + | return ports.BlobInfo{}, e | |
| 132 | + | } | |
| 133 | + | path := s.object(key) | |
| 134 | + | if e = os.MkdirAll(filepath.Dir(path), 0700); e == nil { | |
| 135 | + | e = os.Rename(f.Name(), path) | |
| 136 | + | } | |
| 137 | + | if e == nil { | |
| 138 | + | e = syncDir(filepath.Dir(path)) | |
| 139 | + | } | |
| 140 | + | if e != nil { | |
| 141 | + | return ports.BlobInfo{}, e | |
| 142 | + | } | |
| 143 | + | info, e := s.Stat(ctx, key) | |
| 144 | + | info.Digest = "sha256:" + hex.EncodeToString(h.Sum(nil)) | |
| 145 | + | return info, e | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // Stat returns object metadata. | |
| 149 | + | func (s *Store) Stat(ctx context.Context, key string) (ports.BlobInfo, error) { | |
| 150 | + | if e := valid(key); e != nil { | |
| 151 | + | return ports.BlobInfo{}, e | |
| 152 | + | } | |
| 153 | + | if e := ctx.Err(); e != nil { | |
| 154 | + | return ports.BlobInfo{}, e | |
| 155 | + | } | |
| 156 | + | f, e := os.Stat(s.object(key)) | |
| 157 | + | if e != nil { | |
| 158 | + | return ports.BlobInfo{}, translate(e) | |
| 159 | + | } | |
| 160 | + | if !f.Mode().IsRegular() { | |
| 161 | + | return ports.BlobInfo{}, domain.ErrNotFound | |
| 162 | + | } | |
| 163 | + | return ports.BlobInfo{Key: key, Size: f.Size(), ModTime: f.ModTime()}, nil | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | type limitedCloser struct { | |
| 167 | + | io.Reader | |
| 168 | + | io.Closer | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | // Open reads an object range. | |
| 172 | + | // | |
| 173 | + | //hexcheck:ignore suspicious-business-logic-in-adapter translates the port offset/length contract into a file seek; range rules belong to the port | |
| 174 | + | func (s *Store) Open(ctx context.Context, key string, offset, length int64) (io.ReadCloser, ports.BlobInfo, error) { | |
| 175 | + | info, e := s.Stat(ctx, key) | |
| 176 | + | if e != nil { | |
| 177 | + | return nil, info, e | |
| 178 | + | } | |
| 179 | + | if offset < 0 { | |
| 180 | + | return nil, info, domain.ErrInvalid | |
| 181 | + | } | |
| 182 | + | if offset >= info.Size { | |
| 183 | + | return nil, info, domain.ErrOutOfRange | |
| 184 | + | } | |
| 185 | + | f, e := os.Open(s.object(key)) | |
| 186 | + | if e != nil { | |
| 187 | + | return nil, info, translate(e) | |
| 188 | + | } | |
| 189 | + | if _, e = f.Seek(offset, io.SeekStart); e != nil { | |
| 190 | + | f.Close() | |
| 191 | + | return nil, info, e | |
| 192 | + | } | |
| 193 | + | var r io.Reader = ctxReader{ctx, f} | |
| 194 | + | if length >= 0 { | |
| 195 | + | r = io.LimitReader(r, length) | |
| 196 | + | } | |
| 197 | + | return limitedCloser{r, f}, info, nil | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // Delete removes an object, treating absence as success. | |
| 201 | + | func (s *Store) Delete(ctx context.Context, key string) error { | |
| 202 | + | logEntry := zerowrap.FromCtx(ctx) | |
| 203 | + | logEntry.Debug().Msg("blobdisk: Delete") | |
| 204 | + | if e := valid(key); e != nil { | |
| 205 | + | return e | |
| 206 | + | } | |
| 207 | + | if e := ctx.Err(); e != nil { | |
| 208 | + | return e | |
| 209 | + | } | |
| 210 | + | e := os.Remove(s.object(key)) | |
| 211 | + | if errors.Is(e, fs.ErrNotExist) { | |
| 212 | + | return nil | |
| 213 | + | } | |
| 214 | + | return e | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // List visits matching objects in lexical order. | |
| 218 | + | func (s *Store) List(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error { | |
| 219 | + | logEntry := zerowrap.FromCtx(ctx) | |
| 220 | + | logEntry.Debug().Msg("blobdisk: List") | |
| 221 | + | root := filepath.Join(s.root, "objects") | |
| 222 | + | var infos []ports.BlobInfo | |
| 223 | + | e := filepath.WalkDir(root, func(path string, d fs.DirEntry, e error) error { | |
| 224 | + | if e != nil { | |
| 225 | + | return e | |
| 226 | + | } | |
| 227 | + | if e = ctx.Err(); e != nil { | |
| 228 | + | return e | |
| 229 | + | } | |
| 230 | + | if !d.Type().IsRegular() { | |
| 231 | + | return nil | |
| 232 | + | } | |
| 233 | + | key, e := filepath.Rel(root, path) | |
| 234 | + | if e != nil { | |
| 235 | + | return e | |
| 236 | + | } | |
| 237 | + | key = filepath.ToSlash(key) | |
| 238 | + | if !strings.HasPrefix(key, prefix) { | |
| 239 | + | return nil | |
| 240 | + | } | |
| 241 | + | i, e := d.Info() | |
| 242 | + | if e != nil { | |
| 243 | + | return e | |
| 244 | + | } | |
| 245 | + | infos = append(infos, ports.BlobInfo{Key: key, Size: i.Size(), ModTime: i.ModTime()}) | |
| 246 | + | return nil | |
| 247 | + | }) | |
| 248 | + | if errors.Is(e, fs.ErrNotExist) { | |
| 249 | + | return nil | |
| 250 | + | } | |
| 251 | + | if e != nil { | |
| 252 | + | return e | |
| 253 | + | } | |
| 254 | + | sort.Slice(infos, func(i, j int) bool { return infos[i].Key < infos[j].Key }) | |
| 255 | + | for _, i := range infos { | |
| 256 | + | if e = ctx.Err(); e != nil { | |
| 257 | + | return e | |
| 258 | + | } | |
| 259 | + | if e = fn(i); e != nil { | |
| 260 | + | return e | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | return nil | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | type upload struct { | |
| 267 | + | s *Store | |
| 268 | + | key, id string | |
| 269 | + | done bool | |
| 270 | + | } | |
| 271 | + | type metadata struct { | |
| 272 | + | Key string | |
| 273 | + | Started time.Time | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func (u *upload) dir() string { return filepath.Join(u.s.root, "multipart", u.id) } | |
| 277 | + | ||
| 278 | + | // NewMultipart starts an isolated staging upload. | |
| 279 | + | func (s *Store) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) { | |
| 280 | + | logEntry := zerowrap.FromCtx(ctx) | |
| 281 | + | logEntry.Debug().Msg("blobdisk: NewMultipart") | |
| 282 | + | if e := valid(key); e != nil { | |
| 283 | + | return nil, e | |
| 284 | + | } | |
| 285 | + | if e := s.init(ctx); e != nil { | |
| 286 | + | return nil, e | |
| 287 | + | } | |
| 288 | + | b := make([]byte, 16) | |
| 289 | + | if _, e := rand.Read(b); e != nil { | |
| 290 | + | return nil, e | |
| 291 | + | } | |
| 292 | + | u := &upload{s: s, key: key, id: hex.EncodeToString(b)} | |
| 293 | + | if e := os.Mkdir(u.dir(), 0700); e != nil { | |
| 294 | + | return nil, e | |
| 295 | + | } | |
| 296 | + | data, _ := json.Marshal(metadata{key, time.Now().UTC()}) | |
| 297 | + | if e := os.WriteFile(filepath.Join(u.dir(), "metadata"), data, 0600); e != nil { | |
| 298 | + | os.RemoveAll(u.dir()) | |
| 299 | + | return nil, e | |
| 300 | + | } | |
| 301 | + | return u, nil | |
| 302 | + | } | |
| 303 | + | func (u *upload) ID() string { return u.id } | |
| 304 | + | func (u *upload) UploadPart(ctx context.Context, n int, r io.Reader, size int64) error { | |
| 305 | + | logEntry := zerowrap.FromCtx(ctx) | |
| 306 | + | logEntry.Debug().Msg("blobdisk: UploadPart") | |
| 307 | + | if u.done || n < 1 || n > ports.BlobMaxParts || size < 0 { | |
| 308 | + | return domain.ErrInvalid | |
| 309 | + | } | |
| 310 | + | f, e := os.CreateTemp(u.dir(), ".tmp-*") | |
| 311 | + | if e != nil { | |
| 312 | + | return e | |
| 313 | + | } | |
| 314 | + | defer os.Remove(f.Name()) | |
| 315 | + | count, e := io.Copy(f, ctxReader{ctx, r}) | |
| 316 | + | if e == nil && count != size { | |
| 317 | + | e = domain.ErrInvalid | |
| 318 | + | } | |
| 319 | + | if e == nil { | |
| 320 | + | e = f.Sync() | |
| 321 | + | } | |
| 322 | + | e = errors.Join(e, f.Close()) | |
| 323 | + | if e != nil { | |
| 324 | + | return e | |
| 325 | + | } | |
| 326 | + | return os.Rename(f.Name(), filepath.Join(u.dir(), fmt.Sprintf("%05d", n))) | |
| 327 | + | } | |
| 328 | + | func (u *upload) Complete(ctx context.Context) (ports.BlobInfo, error) { | |
| 329 | + | logEntry := zerowrap.FromCtx(ctx) | |
| 330 | + | logEntry.Debug().Msg("blobdisk: Complete") | |
| 331 | + | if u.done { | |
| 332 | + | return ports.BlobInfo{}, domain.ErrInvalid | |
| 333 | + | } | |
| 334 | + | u.done = true | |
| 335 | + | defer os.RemoveAll(u.dir()) | |
| 336 | + | entries, e := os.ReadDir(u.dir()) | |
| 337 | + | if e != nil { | |
| 338 | + | return ports.BlobInfo{}, e | |
| 339 | + | } | |
| 340 | + | var names []string | |
| 341 | + | for _, d := range entries { | |
| 342 | + | if n, e := strconv.Atoi(d.Name()); e == nil && n > 0 { | |
| 343 | + | names = append(names, d.Name()) | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | sort.Strings(names) | |
| 347 | + | if len(names) == 0 { | |
| 348 | + | return ports.BlobInfo{}, domain.ErrInvalid | |
| 349 | + | } | |
| 350 | + | var size int64 | |
| 351 | + | var readers []io.Reader | |
| 352 | + | var files []*os.File | |
| 353 | + | defer func() { | |
| 354 | + | for _, f := range files { | |
| 355 | + | f.Close() | |
| 356 | + | } | |
| 357 | + | }() | |
| 358 | + | for i, n := range names { | |
| 359 | + | f, e := os.Open(filepath.Join(u.dir(), n)) | |
| 360 | + | if e != nil { | |
| 361 | + | return ports.BlobInfo{}, e | |
| 362 | + | } | |
| 363 | + | files = append(files, f) | |
| 364 | + | info, e := f.Stat() | |
| 365 | + | if e != nil { | |
| 366 | + | return ports.BlobInfo{}, e | |
| 367 | + | } | |
| 368 | + | if i < len(names)-1 && info.Size() < ports.BlobMinPartSize { | |
| 369 | + | return ports.BlobInfo{}, domain.ErrInvalid | |
| 370 | + | } | |
| 371 | + | size += info.Size() | |
| 372 | + | readers = append(readers, f) | |
| 373 | + | } | |
| 374 | + | info, e := u.s.Put(ctx, u.key, io.MultiReader(readers...), size) | |
| 375 | + | info.Digest = "" | |
| 376 | + | return info, e | |
| 377 | + | } | |
| 378 | + | func (u *upload) Abort(ctx context.Context) error { | |
| 379 | + | logEntry := zerowrap.FromCtx(ctx) | |
| 380 | + | logEntry.Debug().Msg("blobdisk: Abort") | |
| 381 | + | if u.done { | |
| 382 | + | return domain.ErrInvalid | |
| 383 | + | } | |
| 384 | + | u.done = true | |
| 385 | + | return u.s.AbortMultipart(ctx, u.key, u.id) | |
| 386 | + | } | |
| 387 | + | func validID(id string) bool { | |
| 388 | + | b, e := hex.DecodeString(id) | |
| 389 | + | return e == nil && len(b) == 16 && id == strings.ToLower(id) | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | // AbortMultipart removes a listed upload by identity. | |
| 393 | + | func (s *Store) AbortMultipart(ctx context.Context, key, id string) error { | |
| 394 | + | logEntry := zerowrap.FromCtx(ctx) | |
| 395 | + | logEntry.Debug().Msg("blobdisk: AbortMultipart") | |
| 396 | + | if e := valid(key); e != nil { | |
| 397 | + | return e | |
| 398 | + | } | |
| 399 | + | if !validID(id) { | |
| 400 | + | return domain.ErrInvalid | |
| 401 | + | } | |
| 402 | + | if e := ctx.Err(); e != nil { | |
| 403 | + | return e | |
| 404 | + | } | |
| 405 | + | dir := filepath.Join(s.root, "multipart", id) | |
| 406 | + | b, e := os.ReadFile(filepath.Join(dir, "metadata")) | |
| 407 | + | if errors.Is(e, fs.ErrNotExist) { | |
| 408 | + | return nil | |
| 409 | + | } | |
| 410 | + | if e != nil { | |
| 411 | + | return e | |
| 412 | + | } | |
| 413 | + | var m metadata | |
| 414 | + | if e = json.Unmarshal(b, &m); e != nil { | |
| 415 | + | return e | |
| 416 | + | } | |
| 417 | + | if m.Key != key { | |
| 418 | + | return domain.ErrInvalid | |
| 419 | + | } | |
| 420 | + | return os.RemoveAll(dir) | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | // ListMultipart visits unfinished uploads matching prefix. | |
| 424 | + | func (s *Store) ListMultipart(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error { | |
| 425 | + | logEntry := zerowrap.FromCtx(ctx) | |
| 426 | + | logEntry.Debug().Msg("blobdisk: ListMultipart") | |
| 427 | + | entries, e := os.ReadDir(filepath.Join(s.root, "multipart")) | |
| 428 | + | if errors.Is(e, fs.ErrNotExist) { | |
| 429 | + | return nil | |
| 430 | + | } | |
| 431 | + | if e != nil { | |
| 432 | + | return e | |
| 433 | + | } | |
| 434 | + | for _, d := range entries { | |
| 435 | + | if e = ctx.Err(); e != nil { | |
| 436 | + | return e | |
| 437 | + | } | |
| 438 | + | if !d.IsDir() || !validID(d.Name()) { | |
| 439 | + | continue | |
| 440 | + | } | |
| 441 | + | b, e := os.ReadFile(filepath.Join(s.root, "multipart", d.Name(), "metadata")) | |
| 442 | + | if e != nil { | |
| 443 | + | return e | |
| 444 | + | } | |
| 445 | + | var m metadata | |
| 446 | + | if e = json.Unmarshal(b, &m); e != nil { | |
| 447 | + | return e | |
| 448 | + | } | |
| 449 | + | if strings.HasPrefix(m.Key, prefix) { | |
| 450 | + | if e = fn(ports.MultipartInfo{Key: m.Key, ID: d.Name(), Started: m.Started}); e != nil { | |
| 451 | + | return e | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | } | |
| 455 | + | return nil | |
| 456 | + | } | |
| @@ -0,0 +1,456 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Package blobdisk stores objects under root/objects, temporary writes under | |||
| 4 | + | // root/tmp, and unfinished multipart uploads under root/multipart. Files are | |||
| 5 | + | // 0600 in 0700 directories; completed objects appear by synced atomic rename. | |||
| 6 | + | package blobdisk | |||
| 7 | + | ||||
| 8 | + | import ( | |||
| 9 | + | "context" | |||
| 10 | + | "crypto/rand" | |||
| 11 | + | "crypto/sha256" | |||
| 12 | + | "encoding/hex" | |||
| 13 | + | "encoding/json" | |||
| 14 | + | "errors" | |||
| 15 | + | "fmt" | |||
| 16 | + | "io" | |||
| 17 | + | "io/fs" | |||
| 18 | + | "os" | |||
| 19 | + | "path/filepath" | |||
| 20 | + | "sort" | |||
| 21 | + | "strconv" | |||
| 22 | + | "strings" | |||
| 23 | + | "time" | |||
| 24 | + | ||||
| 25 | + | "bnema.dev/ermite/internal/domain" | |||
| 26 | + | "bnema.dev/ermite/internal/ports" | |||
| 27 | + | "github.com/bnema/zerowrap" | |||
| 28 | + | ) | |||
| 29 | + | ||||
| 30 | + | // Store is the disk implementation of ports.BlobStore. | |||
| 31 | + | type Store struct{ root string } | |||
| 32 | + | ||||
| 33 | + | var _ ports.BlobStore = (*Store)(nil) | |||
| 34 | + | ||||
| 35 | + | // Dir returns the blob root under dataDir. | |||
| 36 | + | func Dir(dataDir string) string { return filepath.Join(dataDir, "blobs") } | |||
| 37 | + | ||||
| 38 | + | // New returns a disk store without performing I/O. | |||
| 39 | + | func New(root string) *Store { return &Store{root: root} } | |||
| 40 | + | func (s *Store) object(key string) string { | |||
| 41 | + | return filepath.Join(s.root, "objects", filepath.FromSlash(key)) | |||
| 42 | + | } | |||
| 43 | + | func valid(key string) error { | |||
| 44 | + | if !ports.ValidBlobKey(key) { | |||
| 45 | + | return domain.ErrInvalid | |||
| 46 | + | } | |||
| 47 | + | return nil | |||
| 48 | + | } | |||
| 49 | + | func translate(err error) error { | |||
| 50 | + | if errors.Is(err, fs.ErrNotExist) { | |||
| 51 | + | return domain.ErrNotFound | |||
| 52 | + | } | |||
| 53 | + | return err | |||
| 54 | + | } | |||
| 55 | + | func syncDir(path string) error { | |||
| 56 | + | f, e := os.Open(path) | |||
| 57 | + | if e != nil { | |||
| 58 | + | return e | |||
| 59 | + | } | |||
| 60 | + | return errors.Join(f.Sync(), f.Close()) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | type ctxReader struct { | |||
| 64 | + | ctx context.Context | |||
| 65 | + | r io.Reader | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (r ctxReader) Read(p []byte) (int, error) { | |||
| 69 | + | if e := r.ctx.Err(); e != nil { | |||
| 70 | + | return 0, e | |||
| 71 | + | } | |||
| 72 | + | return r.r.Read(p) | |||
| 73 | + | } | |||
| 74 | + | func (s *Store) init(ctx context.Context) error { | |||
| 75 | + | if e := ctx.Err(); e != nil { | |||
| 76 | + | return e | |||
| 77 | + | } | |||
| 78 | + | for _, d := range []string{"objects", "tmp", "multipart"} { | |||
| 79 | + | if e := os.MkdirAll(filepath.Join(s.root, d), 0700); e != nil { | |||
| 80 | + | return e | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | return nil | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // Check verifies that the root is writable. | |||
| 87 | + | func (s *Store) Check(ctx context.Context) error { | |||
| 88 | + | logEntry := zerowrap.FromCtx(ctx) | |||
| 89 | + | logEntry.Debug().Msg("blobdisk: Check") | |||
| 90 | + | if e := s.init(ctx); e != nil { | |||
| 91 | + | return e | |||
| 92 | + | } | |||
| 93 | + | f, e := os.CreateTemp(filepath.Join(s.root, "tmp"), ".probe-*") | |||
| 94 | + | if e != nil { | |||
| 95 | + | return e | |||
| 96 | + | } | |||
| 97 | + | return errors.Join(f.Close(), os.Remove(f.Name())) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // Put atomically replaces key with exactly size bytes. | |||
| 101 | + | func (s *Store) Put(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) { | |||
| 102 | + | logEntry := zerowrap.FromCtx(ctx) | |||
| 103 | + | logEntry.Debug().Msg("blobdisk: Put") | |||
| 104 | + | if e := valid(key); e != nil { | |||
| 105 | + | return ports.BlobInfo{}, e | |||
| 106 | + | } | |||
| 107 | + | if size < 0 { | |||
| 108 | + | return ports.BlobInfo{}, domain.ErrInvalid | |||
| 109 | + | } | |||
| 110 | + | if e := s.init(ctx); e != nil { | |||
| 111 | + | return ports.BlobInfo{}, e | |||
| 112 | + | } | |||
| 113 | + | f, e := os.CreateTemp(filepath.Join(s.root, "tmp"), ".tmp-*") | |||
| 114 | + | if e != nil { | |||
| 115 | + | return ports.BlobInfo{}, e | |||
| 116 | + | } | |||
| 117 | + | defer os.Remove(f.Name()) | |||
| 118 | + | h := sha256.New() | |||
| 119 | + | n, e := io.Copy(io.MultiWriter(f, h), ctxReader{ctx, r}) | |||
| 120 | + | if e == nil && n != size { | |||
| 121 | + | e = domain.ErrInvalid | |||
| 122 | + | } | |||
| 123 | + | if e == nil { | |||
| 124 | + | e = ctx.Err() | |||
| 125 | + | } | |||
| 126 | + | if e == nil { | |||
| 127 | + | e = f.Sync() | |||
| 128 | + | } | |||
| 129 | + | e = errors.Join(e, f.Close()) | |||
| 130 | + | if e != nil { | |||
| 131 | + | return ports.BlobInfo{}, e | |||
| 132 | + | } | |||
| 133 | + | path := s.object(key) | |||
| 134 | + | if e = os.MkdirAll(filepath.Dir(path), 0700); e == nil { | |||
| 135 | + | e = os.Rename(f.Name(), path) | |||
| 136 | + | } | |||
| 137 | + | if e == nil { | |||
| 138 | + | e = syncDir(filepath.Dir(path)) | |||
| 139 | + | } | |||
| 140 | + | if e != nil { | |||
| 141 | + | return ports.BlobInfo{}, e | |||
| 142 | + | } | |||
| 143 | + | info, e := s.Stat(ctx, key) | |||
| 144 | + | info.Digest = "sha256:" + hex.EncodeToString(h.Sum(nil)) | |||
| 145 | + | return info, e | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // Stat returns object metadata. | |||
| 149 | + | func (s *Store) Stat(ctx context.Context, key string) (ports.BlobInfo, error) { | |||
| 150 | + | if e := valid(key); e != nil { | |||
| 151 | + | return ports.BlobInfo{}, e | |||
| 152 | + | } | |||
| 153 | + | if e := ctx.Err(); e != nil { | |||
| 154 | + | return ports.BlobInfo{}, e | |||
| 155 | + | } | |||
| 156 | + | f, e := os.Stat(s.object(key)) | |||
| 157 | + | if e != nil { | |||
| 158 | + | return ports.BlobInfo{}, translate(e) | |||
| 159 | + | } | |||
| 160 | + | if !f.Mode().IsRegular() { | |||
| 161 | + | return ports.BlobInfo{}, domain.ErrNotFound | |||
| 162 | + | } | |||
| 163 | + | return ports.BlobInfo{Key: key, Size: f.Size(), ModTime: f.ModTime()}, nil | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | type limitedCloser struct { | |||
| 167 | + | io.Reader | |||
| 168 | + | io.Closer | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // Open reads an object range. | |||
| 172 | + | // | |||
| 173 | + | //hexcheck:ignore suspicious-business-logic-in-adapter translates the port offset/length contract into a file seek; range rules belong to the port | |||
| 174 | + | func (s *Store) Open(ctx context.Context, key string, offset, length int64) (io.ReadCloser, ports.BlobInfo, error) { | |||
| 175 | + | info, e := s.Stat(ctx, key) | |||
| 176 | + | if e != nil { | |||
| 177 | + | return nil, info, e | |||
| 178 | + | } | |||
| 179 | + | if offset < 0 { | |||
| 180 | + | return nil, info, domain.ErrInvalid | |||
| 181 | + | } | |||
| 182 | + | if offset >= info.Size { | |||
| 183 | + | return nil, info, domain.ErrOutOfRange | |||
| 184 | + | } | |||
| 185 | + | f, e := os.Open(s.object(key)) | |||
| 186 | + | if e != nil { | |||
| 187 | + | return nil, info, translate(e) | |||
| 188 | + | } | |||
| 189 | + | if _, e = f.Seek(offset, io.SeekStart); e != nil { | |||
| 190 | + | f.Close() | |||
| 191 | + | return nil, info, e | |||
| 192 | + | } | |||
| 193 | + | var r io.Reader = ctxReader{ctx, f} | |||
| 194 | + | if length >= 0 { | |||
| 195 | + | r = io.LimitReader(r, length) | |||
| 196 | + | } | |||
| 197 | + | return limitedCloser{r, f}, info, nil | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // Delete removes an object, treating absence as success. | |||
| 201 | + | func (s *Store) Delete(ctx context.Context, key string) error { | |||
| 202 | + | logEntry := zerowrap.FromCtx(ctx) | |||
| 203 | + | logEntry.Debug().Msg("blobdisk: Delete") | |||
| 204 | + | if e := valid(key); e != nil { | |||
| 205 | + | return e | |||
| 206 | + | } | |||
| 207 | + | if e := ctx.Err(); e != nil { | |||
| 208 | + | return e | |||
| 209 | + | } | |||
| 210 | + | e := os.Remove(s.object(key)) | |||
| 211 | + | if errors.Is(e, fs.ErrNotExist) { | |||
| 212 | + | return nil | |||
| 213 | + | } | |||
| 214 | + | return e | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // List visits matching objects in lexical order. | |||
| 218 | + | func (s *Store) List(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error { | |||
| 219 | + | logEntry := zerowrap.FromCtx(ctx) | |||
| 220 | + | logEntry.Debug().Msg("blobdisk: List") | |||
| 221 | + | root := filepath.Join(s.root, "objects") | |||
| 222 | + | var infos []ports.BlobInfo | |||
| 223 | + | e := filepath.WalkDir(root, func(path string, d fs.DirEntry, e error) error { | |||
| 224 | + | if e != nil { | |||
| 225 | + | return e | |||
| 226 | + | } | |||
| 227 | + | if e = ctx.Err(); e != nil { | |||
| 228 | + | return e | |||
| 229 | + | } | |||
| 230 | + | if !d.Type().IsRegular() { | |||
| 231 | + | return nil | |||
| 232 | + | } | |||
| 233 | + | key, e := filepath.Rel(root, path) | |||
| 234 | + | if e != nil { | |||
| 235 | + | return e | |||
| 236 | + | } | |||
| 237 | + | key = filepath.ToSlash(key) | |||
| 238 | + | if !strings.HasPrefix(key, prefix) { | |||
| 239 | + | return nil | |||
| 240 | + | } | |||
| 241 | + | i, e := d.Info() | |||
| 242 | + | if e != nil { | |||
| 243 | + | return e | |||
| 244 | + | } | |||
| 245 | + | infos = append(infos, ports.BlobInfo{Key: key, Size: i.Size(), ModTime: i.ModTime()}) | |||
| 246 | + | return nil | |||
| 247 | + | }) | |||
| 248 | + | if errors.Is(e, fs.ErrNotExist) { | |||
| 249 | + | return nil | |||
| 250 | + | } | |||
| 251 | + | if e != nil { | |||
| 252 | + | return e | |||
| 253 | + | } | |||
| 254 | + | sort.Slice(infos, func(i, j int) bool { return infos[i].Key < infos[j].Key }) | |||
| 255 | + | for _, i := range infos { | |||
| 256 | + | if e = ctx.Err(); e != nil { | |||
| 257 | + | return e | |||
| 258 | + | } | |||
| 259 | + | if e = fn(i); e != nil { | |||
| 260 | + | return e | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | return nil | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | type upload struct { | |||
| 267 | + | s *Store | |||
| 268 | + | key, id string | |||
| 269 | + | done bool | |||
| 270 | + | } | |||
| 271 | + | type metadata struct { | |||
| 272 | + | Key string | |||
| 273 | + | Started time.Time | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | func (u *upload) dir() string { return filepath.Join(u.s.root, "multipart", u.id) } | |||
| 277 | + | ||||
| 278 | + | // NewMultipart starts an isolated staging upload. | |||
| 279 | + | func (s *Store) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) { | |||
| 280 | + | logEntry := zerowrap.FromCtx(ctx) | |||
| 281 | + | logEntry.Debug().Msg("blobdisk: NewMultipart") | |||
| 282 | + | if e := valid(key); e != nil { | |||
| 283 | + | return nil, e | |||
| 284 | + | } | |||
| 285 | + | if e := s.init(ctx); e != nil { | |||
| 286 | + | return nil, e | |||
| 287 | + | } | |||
| 288 | + | b := make([]byte, 16) | |||
| 289 | + | if _, e := rand.Read(b); e != nil { | |||
| 290 | + | return nil, e | |||
| 291 | + | } | |||
| 292 | + | u := &upload{s: s, key: key, id: hex.EncodeToString(b)} | |||
| 293 | + | if e := os.Mkdir(u.dir(), 0700); e != nil { | |||
| 294 | + | return nil, e | |||
| 295 | + | } | |||
| 296 | + | data, _ := json.Marshal(metadata{key, time.Now().UTC()}) | |||
| 297 | + | if e := os.WriteFile(filepath.Join(u.dir(), "metadata"), data, 0600); e != nil { | |||
| 298 | + | os.RemoveAll(u.dir()) | |||
| 299 | + | return nil, e | |||
| 300 | + | } | |||
| 301 | + | return u, nil | |||
| 302 | + | } | |||
| 303 | + | func (u *upload) ID() string { return u.id } | |||
| 304 | + | func (u *upload) UploadPart(ctx context.Context, n int, r io.Reader, size int64) error { | |||
| 305 | + | logEntry := zerowrap.FromCtx(ctx) | |||
| 306 | + | logEntry.Debug().Msg("blobdisk: UploadPart") | |||
| 307 | + | if u.done || n < 1 || n > ports.BlobMaxParts || size < 0 { | |||
| 308 | + | return domain.ErrInvalid | |||
| 309 | + | } | |||
| 310 | + | f, e := os.CreateTemp(u.dir(), ".tmp-*") | |||
| 311 | + | if e != nil { | |||
| 312 | + | return e | |||
| 313 | + | } | |||
| 314 | + | defer os.Remove(f.Name()) | |||
| 315 | + | count, e := io.Copy(f, ctxReader{ctx, r}) | |||
| 316 | + | if e == nil && count != size { | |||
| 317 | + | e = domain.ErrInvalid | |||
| 318 | + | } | |||
| 319 | + | if e == nil { | |||
| 320 | + | e = f.Sync() | |||
| 321 | + | } | |||
| 322 | + | e = errors.Join(e, f.Close()) | |||
| 323 | + | if e != nil { | |||
| 324 | + | return e | |||
| 325 | + | } | |||
| 326 | + | return os.Rename(f.Name(), filepath.Join(u.dir(), fmt.Sprintf("%05d", n))) | |||
| 327 | + | } | |||
| 328 | + | func (u *upload) Complete(ctx context.Context) (ports.BlobInfo, error) { | |||
| 329 | + | logEntry := zerowrap.FromCtx(ctx) | |||
| 330 | + | logEntry.Debug().Msg("blobdisk: Complete") | |||
| 331 | + | if u.done { | |||
| 332 | + | return ports.BlobInfo{}, domain.ErrInvalid | |||
| 333 | + | } | |||
| 334 | + | u.done = true | |||
| 335 | + | defer os.RemoveAll(u.dir()) | |||
| 336 | + | entries, e := os.ReadDir(u.dir()) | |||
| 337 | + | if e != nil { | |||
| 338 | + | return ports.BlobInfo{}, e | |||
| 339 | + | } | |||
| 340 | + | var names []string | |||
| 341 | + | for _, d := range entries { | |||
| 342 | + | if n, e := strconv.Atoi(d.Name()); e == nil && n > 0 { | |||
| 343 | + | names = append(names, d.Name()) | |||
| 344 | + | } | |||
| 345 | + | } | |||
| 346 | + | sort.Strings(names) | |||
| 347 | + | if len(names) == 0 { | |||
| 348 | + | return ports.BlobInfo{}, domain.ErrInvalid | |||
| 349 | + | } | |||
| 350 | + | var size int64 | |||
| 351 | + | var readers []io.Reader | |||
| 352 | + | var files []*os.File | |||
| 353 | + | defer func() { | |||
| 354 | + | for _, f := range files { | |||
| 355 | + | f.Close() | |||
| 356 | + | } | |||
| 357 | + | }() | |||
| 358 | + | for i, n := range names { | |||
| 359 | + | f, e := os.Open(filepath.Join(u.dir(), n)) | |||
| 360 | + | if e != nil { | |||
| 361 | + | return ports.BlobInfo{}, e | |||
| 362 | + | } | |||
| 363 | + | files = append(files, f) | |||
| 364 | + | info, e := f.Stat() | |||
| 365 | + | if e != nil { | |||
| 366 | + | return ports.BlobInfo{}, e | |||
| 367 | + | } | |||
| 368 | + | if i < len(names)-1 && info.Size() < ports.BlobMinPartSize { | |||
| 369 | + | return ports.BlobInfo{}, domain.ErrInvalid | |||
| 370 | + | } | |||
| 371 | + | size += info.Size() | |||
| 372 | + | readers = append(readers, f) | |||
| 373 | + | } | |||
| 374 | + | info, e := u.s.Put(ctx, u.key, io.MultiReader(readers...), size) | |||
| 375 | + | info.Digest = "" | |||
| 376 | + | return info, e | |||
| 377 | + | } | |||
| 378 | + | func (u *upload) Abort(ctx context.Context) error { | |||
| 379 | + | logEntry := zerowrap.FromCtx(ctx) | |||
| 380 | + | logEntry.Debug().Msg("blobdisk: Abort") | |||
| 381 | + | if u.done { | |||
| 382 | + | return domain.ErrInvalid | |||
| 383 | + | } | |||
| 384 | + | u.done = true | |||
| 385 | + | return u.s.AbortMultipart(ctx, u.key, u.id) | |||
| 386 | + | } | |||
| 387 | + | func validID(id string) bool { | |||
| 388 | + | b, e := hex.DecodeString(id) | |||
| 389 | + | return e == nil && len(b) == 16 && id == strings.ToLower(id) | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | // AbortMultipart removes a listed upload by identity. | |||
| 393 | + | func (s *Store) AbortMultipart(ctx context.Context, key, id string) error { | |||
| 394 | + | logEntry := zerowrap.FromCtx(ctx) | |||
| 395 | + | logEntry.Debug().Msg("blobdisk: AbortMultipart") | |||
| 396 | + | if e := valid(key); e != nil { | |||
| 397 | + | return e | |||
| 398 | + | } | |||
| 399 | + | if !validID(id) { | |||
| 400 | + | return domain.ErrInvalid | |||
| 401 | + | } | |||
| 402 | + | if e := ctx.Err(); e != nil { | |||
| 403 | + | return e | |||
| 404 | + | } | |||
| 405 | + | dir := filepath.Join(s.root, "multipart", id) | |||
| 406 | + | b, e := os.ReadFile(filepath.Join(dir, "metadata")) | |||
| 407 | + | if errors.Is(e, fs.ErrNotExist) { | |||
| 408 | + | return nil | |||
| 409 | + | } | |||
| 410 | + | if e != nil { | |||
| 411 | + | return e | |||
| 412 | + | } | |||
| 413 | + | var m metadata | |||
| 414 | + | if e = json.Unmarshal(b, &m); e != nil { | |||
| 415 | + | return e | |||
| 416 | + | } | |||
| 417 | + | if m.Key != key { | |||
| 418 | + | return domain.ErrInvalid | |||
| 419 | + | } | |||
| 420 | + | return os.RemoveAll(dir) | |||
| 421 | + | } | |||
| 422 | + | ||||
| 423 | + | // ListMultipart visits unfinished uploads matching prefix. | |||
| 424 | + | func (s *Store) ListMultipart(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error { | |||
| 425 | + | logEntry := zerowrap.FromCtx(ctx) | |||
| 426 | + | logEntry.Debug().Msg("blobdisk: ListMultipart") | |||
| 427 | + | entries, e := os.ReadDir(filepath.Join(s.root, "multipart")) | |||
| 428 | + | if errors.Is(e, fs.ErrNotExist) { | |||
| 429 | + | return nil | |||
| 430 | + | } | |||
| 431 | + | if e != nil { | |||
| 432 | + | return e | |||
| 433 | + | } | |||
| 434 | + | for _, d := range entries { | |||
| 435 | + | if e = ctx.Err(); e != nil { | |||
| 436 | + | return e | |||
| 437 | + | } | |||
| 438 | + | if !d.IsDir() || !validID(d.Name()) { | |||
| 439 | + | continue | |||
| 440 | + | } | |||
| 441 | + | b, e := os.ReadFile(filepath.Join(s.root, "multipart", d.Name(), "metadata")) | |||
| 442 | + | if e != nil { | |||
| 443 | + | return e | |||
| 444 | + | } | |||
| 445 | + | var m metadata | |||
| 446 | + | if e = json.Unmarshal(b, &m); e != nil { | |||
| 447 | + | return e | |||
| 448 | + | } | |||
| 449 | + | if strings.HasPrefix(m.Key, prefix) { | |||
| 450 | + | if e = fn(ports.MultipartInfo{Key: m.Key, ID: d.Name(), Started: m.Started}); e != nil { | |||
| 451 | + | return e | |||
| 452 | + | } | |||
| 453 | + | } | |||
| 454 | + | } | |||
| 455 | + | return nil | |||
| 456 | + | } | |||
A internal/adapters/blobdisk/blobdisk_test.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | package blobdisk_test | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/adapters/blobdisk" | |
| 11 | + | "bnema.dev/ermite/internal/adapters/blobtest" | |
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestContract(t *testing.T) { | |
| 18 | + | blobtest.Run(t, func(t *testing.T) ports.BlobStore { return blobdisk.New(t.TempDir()) }) | |
| 19 | + | } | |
| 20 | + | func TestCrashTempInvisible(t *testing.T) { | |
| 21 | + | root := t.TempDir() | |
| 22 | + | s := blobdisk.New(root) | |
| 23 | + | ctx := context.Background() | |
| 24 | + | require.NoError(t, s.Check(ctx)) | |
| 25 | + | require.NoError(t, os.WriteFile(filepath.Join(root, "tmp", ".tmp-crashed"), []byte("partial"), 0600)) | |
| 26 | + | require.NoError(t, s.List(ctx, "", func(ports.BlobInfo) error { t.Fatal("temporary object listed"); return nil })) | |
| 27 | + | _, e := s.Stat(ctx, ".tmp-crashed") | |
| 28 | + | require.ErrorIs(t, e, domain.ErrNotFound) | |
| 29 | + | } | |
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | package blobdisk_test | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/adapters/blobdisk" | |||
| 11 | + | "bnema.dev/ermite/internal/adapters/blobtest" | |||
| 12 | + | "bnema.dev/ermite/internal/domain" | |||
| 13 | + | "bnema.dev/ermite/internal/ports" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestContract(t *testing.T) { | |||
| 18 | + | blobtest.Run(t, func(t *testing.T) ports.BlobStore { return blobdisk.New(t.TempDir()) }) | |||
| 19 | + | } | |||
| 20 | + | func TestCrashTempInvisible(t *testing.T) { | |||
| 21 | + | root := t.TempDir() | |||
| 22 | + | s := blobdisk.New(root) | |||
| 23 | + | ctx := context.Background() | |||
| 24 | + | require.NoError(t, s.Check(ctx)) | |||
| 25 | + | require.NoError(t, os.WriteFile(filepath.Join(root, "tmp", ".tmp-crashed"), []byte("partial"), 0600)) | |||
| 26 | + | require.NoError(t, s.List(ctx, "", func(ports.BlobInfo) error { t.Fatal("temporary object listed"); return nil })) | |||
| 27 | + | _, e := s.Stat(ctx, ".tmp-crashed") | |||
| 28 | + | require.ErrorIs(t, e, domain.ErrNotFound) | |||
| 29 | + | } | |||
A internal/adapters/blobs3/blobs3.go +461 -0
| @@ -0,0 +1,461 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package blobs3 implements the blob port with the object, multipart and | |
| 4 | + | // listing S3 APIs. Checksums are WhenRequired, credentials are explicit, and | |
| 5 | + | // no bucket creation, policy, ACL, tagging or lifecycle API is ever called. | |
| 6 | + | package blobs3 | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "bytes" | |
| 10 | + | "context" | |
| 11 | + | "crypto/sha256" | |
| 12 | + | "encoding/hex" | |
| 13 | + | "errors" | |
| 14 | + | "fmt" | |
| 15 | + | "io" | |
| 16 | + | "net/http" | |
| 17 | + | "sort" | |
| 18 | + | "strconv" | |
| 19 | + | "strings" | |
| 20 | + | "sync" | |
| 21 | + | "time" | |
| 22 | + | ||
| 23 | + | "bnema.dev/ermite/internal/domain" | |
| 24 | + | "bnema.dev/ermite/internal/ports" | |
| 25 | + | "github.com/aws/aws-sdk-go-v2/aws" | |
| 26 | + | "github.com/aws/aws-sdk-go-v2/credentials" | |
| 27 | + | "github.com/aws/aws-sdk-go-v2/service/s3" | |
| 28 | + | "github.com/aws/aws-sdk-go-v2/service/s3/types" | |
| 29 | + | "github.com/bnema/zerowrap" | |
| 30 | + | ) | |
| 31 | + | ||
| 32 | + | // Store is a provider-independent S3 blob store. | |
| 33 | + | type Store struct { | |
| 34 | + | client *s3.Client | |
| 35 | + | cfg Config | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | var _ ports.BlobStore = (*Store)(nil) | |
| 39 | + | ||
| 40 | + | // New builds a client without consulting ambient configuration or probing. | |
| 41 | + | func New(ctx context.Context, cfg Config) (*Store, error) { | |
| 42 | + | if e := ctx.Err(); e != nil { | |
| 43 | + | return nil, e | |
| 44 | + | } | |
| 45 | + | if cfg.Bucket == "" || cfg.Region == "" || cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" { | |
| 46 | + | return nil, fmt.Errorf("bucket, region and explicit credentials are required: %w", domain.ErrInvalid) | |
| 47 | + | } | |
| 48 | + | cfg.Prefix = strings.Trim(cfg.Prefix, "/") | |
| 49 | + | if cfg.Prefix != "" { | |
| 50 | + | cfg.Prefix += "/" | |
| 51 | + | } | |
| 52 | + | client := cfg.HTTPClient | |
| 53 | + | if client == nil { | |
| 54 | + | transport := http.DefaultTransport.(*http.Transport).Clone() | |
| 55 | + | transport.IdleConnTimeout = 60 * time.Second | |
| 56 | + | transport.ResponseHeaderTimeout = 60 * time.Second | |
| 57 | + | client = &http.Client{Transport: transport} | |
| 58 | + | } | |
| 59 | + | ac := aws.Config{Region: cfg.Region, Credentials: credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""), HTTPClient: client, RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired, ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired, RetryMaxAttempts: 3} | |
| 60 | + | c := s3.NewFromConfig(ac, func(o *s3.Options) { | |
| 61 | + | if cfg.Endpoint != "" { | |
| 62 | + | o.BaseEndpoint = aws.String(cfg.Endpoint) | |
| 63 | + | } | |
| 64 | + | o.UsePathStyle = cfg.ForcePathStyle | |
| 65 | + | }) | |
| 66 | + | return &Store{client: c, cfg: cfg}, nil | |
| 67 | + | } | |
| 68 | + | func valid(key string) error { | |
| 69 | + | if !ports.ValidBlobKey(key) { | |
| 70 | + | return domain.ErrInvalid | |
| 71 | + | } | |
| 72 | + | return nil | |
| 73 | + | } | |
| 74 | + | func (s *Store) key(key string) *string { return aws.String(s.cfg.Prefix + key) } | |
| 75 | + | ||
| 76 | + | type statusError interface{ HTTPStatusCode() int } | |
| 77 | + | type codeError interface{ ErrorCode() string } | |
| 78 | + | ||
| 79 | + | func status(err error) int { | |
| 80 | + | var e statusError | |
| 81 | + | if errors.As(err, &e) { | |
| 82 | + | return e.HTTPStatusCode() | |
| 83 | + | } | |
| 84 | + | return 0 | |
| 85 | + | } | |
| 86 | + | func translate(err error) error { | |
| 87 | + | if err == nil { | |
| 88 | + | return nil | |
| 89 | + | } | |
| 90 | + | var c codeError | |
| 91 | + | if errors.As(err, &c) { | |
| 92 | + | switch c.ErrorCode() { | |
| 93 | + | case "InvalidRange": | |
| 94 | + | return domain.ErrOutOfRange | |
| 95 | + | case "NoSuchKey", "NotFound": | |
| 96 | + | return domain.ErrNotFound | |
| 97 | + | case "EntityTooSmall": | |
| 98 | + | return domain.ErrInvalid | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | switch status(err) { | |
| 102 | + | case 404: | |
| 103 | + | return domain.ErrNotFound | |
| 104 | + | case 416: | |
| 105 | + | return domain.ErrOutOfRange | |
| 106 | + | } | |
| 107 | + | return err | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | type ctxReader struct { | |
| 111 | + | ctx context.Context | |
| 112 | + | r io.Reader | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (r ctxReader) Read(p []byte) (int, error) { | |
| 116 | + | if e := r.ctx.Err(); e != nil { | |
| 117 | + | return 0, e | |
| 118 | + | } | |
| 119 | + | return r.r.Read(p) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // One pooled buffer per in-flight upload bounds staging to BlobPartSize. | |
| 123 | + | var buffers = sync.Pool{New: func() any { b := make([]byte, ports.BlobPartSize); return &b }} | |
| 124 | + | ||
| 125 | + | func exactEnd(ctx context.Context, r io.Reader) error { | |
| 126 | + | var b [1]byte | |
| 127 | + | n, e := io.ReadFull(ctxReader{ctx, r}, b[:]) | |
| 128 | + | if n != 0 { | |
| 129 | + | return domain.ErrInvalid | |
| 130 | + | } | |
| 131 | + | if e == io.EOF { | |
| 132 | + | return nil | |
| 133 | + | } | |
| 134 | + | return e | |
| 135 | + | } | |
| 136 | + | func readPart(ctx context.Context, r io.Reader, b []byte) error { | |
| 137 | + | _, e := io.ReadFull(ctxReader{ctx, r}, b) | |
| 138 | + | if e == io.EOF || e == io.ErrUnexpectedEOF { | |
| 139 | + | return domain.ErrInvalid | |
| 140 | + | } | |
| 141 | + | return e | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // Put uses signed, seekable bodies, multiparting objects larger than one buffer. | |
| 145 | + | func (s *Store) Put(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) { | |
| 146 | + | logEntry := zerowrap.FromCtx(ctx) | |
| 147 | + | logEntry.Debug().Msg("blobs3: Put") | |
| 148 | + | if e := valid(key); e != nil { | |
| 149 | + | return ports.BlobInfo{}, e | |
| 150 | + | } | |
| 151 | + | if size < 0 || size > int64(ports.BlobPartSize)*ports.BlobMaxParts { | |
| 152 | + | return ports.BlobInfo{}, domain.ErrInvalid | |
| 153 | + | } | |
| 154 | + | bp := buffers.Get().(*[]byte) | |
| 155 | + | defer buffers.Put(bp) | |
| 156 | + | h := sha256.New() | |
| 157 | + | source := io.TeeReader(r, h) | |
| 158 | + | if size <= ports.BlobPartSize { | |
| 159 | + | b := (*bp)[:int(size)] | |
| 160 | + | if e := readPart(ctx, source, b); e != nil { | |
| 161 | + | return ports.BlobInfo{}, e | |
| 162 | + | } | |
| 163 | + | if e := exactEnd(ctx, r); e != nil { | |
| 164 | + | return ports.BlobInfo{}, e | |
| 165 | + | } | |
| 166 | + | _, e := s.client.PutObject(ctx, &s3.PutObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), Body: bytes.NewReader(b), ContentLength: aws.Int64(size)}) | |
| 167 | + | if e != nil { | |
| 168 | + | return ports.BlobInfo{}, translate(e) | |
| 169 | + | } | |
| 170 | + | } else { | |
| 171 | + | u, e := s.NewMultipart(ctx, key) | |
| 172 | + | if e != nil { | |
| 173 | + | return ports.BlobInfo{}, e | |
| 174 | + | } | |
| 175 | + | completed := false | |
| 176 | + | defer func() { | |
| 177 | + | if !completed { | |
| 178 | + | cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second) | |
| 179 | + | defer cancel() | |
| 180 | + | _ = s.AbortMultipart(cleanup, key, u.ID()) | |
| 181 | + | } | |
| 182 | + | }() | |
| 183 | + | remaining := size | |
| 184 | + | for n := 1; remaining > 0; n++ { | |
| 185 | + | count := min(remaining, int64(ports.BlobPartSize)) | |
| 186 | + | b := (*bp)[:int(count)] | |
| 187 | + | if e = readPart(ctx, source, b); e != nil { | |
| 188 | + | return ports.BlobInfo{}, e | |
| 189 | + | } | |
| 190 | + | if e = u.UploadPart(ctx, n, bytes.NewReader(b), count); e != nil { | |
| 191 | + | return ports.BlobInfo{}, e | |
| 192 | + | } | |
| 193 | + | remaining -= count | |
| 194 | + | } | |
| 195 | + | if e = exactEnd(ctx, r); e != nil { | |
| 196 | + | return ports.BlobInfo{}, e | |
| 197 | + | } | |
| 198 | + | if _, e = u.Complete(ctx); e != nil { | |
| 199 | + | return ports.BlobInfo{}, e | |
| 200 | + | } | |
| 201 | + | completed = true | |
| 202 | + | } | |
| 203 | + | log := zerowrap.FromCtx(ctx) | |
| 204 | + | log.Debug().Str("key", key).Int64("size", size).Msg("blob stored") | |
| 205 | + | return ports.BlobInfo{Key: key, Size: size, Digest: "sha256:" + hex.EncodeToString(h.Sum(nil)), ModTime: time.Now().UTC()}, nil | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // Stat retrieves metadata without downloading content. | |
| 209 | + | func (s *Store) Stat(ctx context.Context, key string) (ports.BlobInfo, error) { | |
| 210 | + | if e := valid(key); e != nil { | |
| 211 | + | return ports.BlobInfo{}, e | |
| 212 | + | } | |
| 213 | + | o, e := s.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)}) | |
| 214 | + | if e != nil { | |
| 215 | + | return ports.BlobInfo{}, translate(e) | |
| 216 | + | } | |
| 217 | + | return ports.BlobInfo{Key: key, Size: aws.ToInt64(o.ContentLength), ModTime: aws.ToTime(o.LastModified)}, nil | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | // Open streams a single range of an object. | |
| 221 | + | // | |
| 222 | + | //hexcheck:ignore suspicious-business-logic-in-adapter translates the port offset/length contract into an HTTP Range header; range rules belong to the port | |
| 223 | + | func (s *Store) Open(ctx context.Context, key string, offset, length int64) (io.ReadCloser, ports.BlobInfo, error) { | |
| 224 | + | if e := valid(key); e != nil { | |
| 225 | + | return nil, ports.BlobInfo{}, e | |
| 226 | + | } | |
| 227 | + | if offset < 0 { | |
| 228 | + | return nil, ports.BlobInfo{}, domain.ErrInvalid | |
| 229 | + | } | |
| 230 | + | if length == 0 { | |
| 231 | + | info, e := s.Stat(ctx, key) | |
| 232 | + | if e != nil { | |
| 233 | + | return nil, info, e | |
| 234 | + | } | |
| 235 | + | if offset >= info.Size { | |
| 236 | + | return nil, info, domain.ErrOutOfRange | |
| 237 | + | } | |
| 238 | + | return io.NopCloser(strings.NewReader("")), info, nil | |
| 239 | + | } | |
| 240 | + | rg := fmt.Sprintf("bytes=%d-", offset) | |
| 241 | + | if length > 0 && length-1 <= int64(^uint64(0)>>1)-offset { | |
| 242 | + | rg += strconv.FormatInt(offset+length-1, 10) | |
| 243 | + | } | |
| 244 | + | o, e := s.client.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), Range: aws.String(rg)}) | |
| 245 | + | if e != nil { | |
| 246 | + | return nil, ports.BlobInfo{}, translate(e) | |
| 247 | + | } | |
| 248 | + | size := aws.ToInt64(o.ContentLength) | |
| 249 | + | if _, total, ok := strings.Cut(aws.ToString(o.ContentRange), "/"); ok { | |
| 250 | + | if n, e := strconv.ParseInt(total, 10, 64); e == nil { | |
| 251 | + | size = n | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | return o.Body, ports.BlobInfo{Key: key, Size: size, ModTime: aws.ToTime(o.LastModified)}, nil | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | // Delete removes a key; absence is success. | |
| 258 | + | func (s *Store) Delete(ctx context.Context, key string) error { | |
| 259 | + | logEntry := zerowrap.FromCtx(ctx) | |
| 260 | + | logEntry.Debug().Msg("blobs3: Delete") | |
| 261 | + | if e := valid(key); e != nil { | |
| 262 | + | return e | |
| 263 | + | } | |
| 264 | + | _, e := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)}) | |
| 265 | + | if status(e) == 404 { | |
| 266 | + | return nil | |
| 267 | + | } | |
| 268 | + | return translate(e) | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | // List visits matching objects lexicographically. | |
| 272 | + | func (s *Store) List(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error { | |
| 273 | + | logEntry := zerowrap.FromCtx(ctx) | |
| 274 | + | logEntry.Debug().Msg("blobs3: List") | |
| 275 | + | p := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{Bucket: aws.String(s.cfg.Bucket), Prefix: s.key(prefix)}) | |
| 276 | + | for p.HasMorePages() { | |
| 277 | + | o, e := p.NextPage(ctx) | |
| 278 | + | if e != nil { | |
| 279 | + | return translate(e) | |
| 280 | + | } | |
| 281 | + | for _, v := range o.Contents { | |
| 282 | + | if e = fn(ports.BlobInfo{Key: strings.TrimPrefix(aws.ToString(v.Key), s.cfg.Prefix), Size: aws.ToInt64(v.Size), ModTime: aws.ToTime(v.LastModified)}); e != nil { | |
| 283 | + | return e | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | return nil | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | // Check probes the existing bucket without modifying it. | |
| 291 | + | func (s *Store) Check(ctx context.Context) error { | |
| 292 | + | logEntry := zerowrap.FromCtx(ctx) | |
| 293 | + | logEntry.Debug().Msg("blobs3: Check") | |
| 294 | + | _, e := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.cfg.Bucket)}) | |
| 295 | + | if status(e) == 403 { | |
| 296 | + | _, e = s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: aws.String(s.cfg.Bucket), Prefix: aws.String(s.cfg.Prefix), MaxKeys: aws.Int32(1)}) | |
| 297 | + | } | |
| 298 | + | if e == nil { | |
| 299 | + | return nil | |
| 300 | + | } | |
| 301 | + | where := s.cfg.Endpoint | |
| 302 | + | if where == "" { | |
| 303 | + | where = s.cfg.Region | |
| 304 | + | } | |
| 305 | + | return fmt.Errorf("bucket %q is not reachable at %s: %w", s.cfg.Bucket, where, e) | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | type part struct { | |
| 309 | + | etag string | |
| 310 | + | size int64 | |
| 311 | + | } | |
| 312 | + | type upload struct { | |
| 313 | + | s *Store | |
| 314 | + | key, id string | |
| 315 | + | parts map[int]part | |
| 316 | + | done bool | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | // NewMultipart initiates an object upload. | |
| 320 | + | func (s *Store) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) { | |
| 321 | + | logEntry := zerowrap.FromCtx(ctx) | |
| 322 | + | logEntry.Debug().Msg("blobs3: NewMultipart") | |
| 323 | + | if e := valid(key); e != nil { | |
| 324 | + | return nil, e | |
| 325 | + | } | |
| 326 | + | o, e := s.client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)}) | |
| 327 | + | if e != nil { | |
| 328 | + | return nil, translate(e) | |
| 329 | + | } | |
| 330 | + | return &upload{s: s, key: key, id: aws.ToString(o.UploadId), parts: map[int]part{}}, nil | |
| 331 | + | } | |
| 332 | + | func (u *upload) ID() string { return u.id } | |
| 333 | + | func (u *upload) UploadPart(ctx context.Context, n int, r io.Reader, size int64) error { | |
| 334 | + | logEntry := zerowrap.FromCtx(ctx) | |
| 335 | + | logEntry.Debug().Msg("blobs3: UploadPart") | |
| 336 | + | if u.done || n < 1 || n > ports.BlobMaxParts || size < 0 || size > ports.BlobPartSize { | |
| 337 | + | return domain.ErrInvalid | |
| 338 | + | } | |
| 339 | + | if e := ctx.Err(); e != nil { | |
| 340 | + | return e | |
| 341 | + | } | |
| 342 | + | var body io.ReadSeeker | |
| 343 | + | if seeker, ok := r.(io.ReadSeeker); ok { | |
| 344 | + | start, e := seeker.Seek(0, io.SeekCurrent) | |
| 345 | + | if e != nil { | |
| 346 | + | return e | |
| 347 | + | } | |
| 348 | + | end, e := seeker.Seek(0, io.SeekEnd) | |
| 349 | + | if e != nil { | |
| 350 | + | return e | |
| 351 | + | } | |
| 352 | + | if _, e = seeker.Seek(start, io.SeekStart); e != nil { | |
| 353 | + | return e | |
| 354 | + | } | |
| 355 | + | if end-start != size { | |
| 356 | + | return domain.ErrInvalid | |
| 357 | + | } | |
| 358 | + | body = seeker | |
| 359 | + | } else { | |
| 360 | + | bp := buffers.Get().(*[]byte) | |
| 361 | + | defer buffers.Put(bp) | |
| 362 | + | b := (*bp)[:int(size)] | |
| 363 | + | if e := readPart(ctx, r, b); e != nil { | |
| 364 | + | return e | |
| 365 | + | } | |
| 366 | + | if e := exactEnd(ctx, r); e != nil { | |
| 367 | + | return e | |
| 368 | + | } | |
| 369 | + | body = bytes.NewReader(b) | |
| 370 | + | } | |
| 371 | + | o, e := u.s.client.UploadPart(ctx, &s3.UploadPartInput{Bucket: aws.String(u.s.cfg.Bucket), Key: u.s.key(u.key), UploadId: aws.String(u.id), PartNumber: aws.Int32(int32(n)), Body: body, ContentLength: aws.Int64(size)}) | |
| 372 | + | if e != nil { | |
| 373 | + | return translate(e) | |
| 374 | + | } | |
| 375 | + | u.parts[n] = part{aws.ToString(o.ETag), size} | |
| 376 | + | log := zerowrap.FromCtx(ctx) | |
| 377 | + | log.Debug().Int("part", n).Msg("blob part uploaded") | |
| 378 | + | return nil | |
| 379 | + | } | |
| 380 | + | func (u *upload) Complete(ctx context.Context) (ports.BlobInfo, error) { | |
| 381 | + | logEntry := zerowrap.FromCtx(ctx) | |
| 382 | + | logEntry.Debug().Msg("blobs3: Complete") | |
| 383 | + | if u.done { | |
| 384 | + | return ports.BlobInfo{}, domain.ErrInvalid | |
| 385 | + | } | |
| 386 | + | u.done = true | |
| 387 | + | var nums []int | |
| 388 | + | for n := range u.parts { | |
| 389 | + | nums = append(nums, n) | |
| 390 | + | } | |
| 391 | + | sort.Ints(nums) | |
| 392 | + | var parts []types.CompletedPart | |
| 393 | + | var size int64 | |
| 394 | + | invalid := len(nums) == 0 | |
| 395 | + | for i, n := range nums { | |
| 396 | + | p := u.parts[n] | |
| 397 | + | if i < len(nums)-1 && p.size < ports.BlobMinPartSize { | |
| 398 | + | invalid = true | |
| 399 | + | } | |
| 400 | + | size += p.size | |
| 401 | + | parts = append(parts, types.CompletedPart{PartNumber: aws.Int32(int32(n)), ETag: aws.String(p.etag)}) | |
| 402 | + | } | |
| 403 | + | if invalid { | |
| 404 | + | _ = u.s.AbortMultipart(ctx, u.key, u.id) | |
| 405 | + | return ports.BlobInfo{}, domain.ErrInvalid | |
| 406 | + | } | |
| 407 | + | _, e := u.s.client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{Bucket: aws.String(u.s.cfg.Bucket), Key: u.s.key(u.key), UploadId: aws.String(u.id), MultipartUpload: &types.CompletedMultipartUpload{Parts: parts}}) | |
| 408 | + | if e != nil { | |
| 409 | + | cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second) | |
| 410 | + | defer cancel() | |
| 411 | + | _ = u.s.AbortMultipart(cleanup, u.key, u.id) | |
| 412 | + | return ports.BlobInfo{}, translate(e) | |
| 413 | + | } | |
| 414 | + | return ports.BlobInfo{Key: u.key, Size: size, ModTime: time.Now().UTC()}, nil | |
| 415 | + | } | |
| 416 | + | func (u *upload) Abort(ctx context.Context) error { | |
| 417 | + | logEntry := zerowrap.FromCtx(ctx) | |
| 418 | + | logEntry.Debug().Msg("blobs3: Abort") | |
| 419 | + | if u.done { | |
| 420 | + | return domain.ErrInvalid | |
| 421 | + | } | |
| 422 | + | u.done = true | |
| 423 | + | return u.s.AbortMultipart(ctx, u.key, u.id) | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | // AbortMultipart discards a listed upload. | |
| 427 | + | func (s *Store) AbortMultipart(ctx context.Context, key, id string) error { | |
| 428 | + | logEntry := zerowrap.FromCtx(ctx) | |
| 429 | + | logEntry.Debug().Msg("blobs3: AbortMultipart") | |
| 430 | + | if e := valid(key); e != nil { | |
| 431 | + | return e | |
| 432 | + | } | |
| 433 | + | _, e := s.client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), UploadId: aws.String(id)}) | |
| 434 | + | if status(e) == 404 { | |
| 435 | + | return nil | |
| 436 | + | } | |
| 437 | + | return translate(e) | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | // ListMultipart visits unfinished uploads matching prefix. | |
| 441 | + | func (s *Store) ListMultipart(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error { | |
| 442 | + | logEntry := zerowrap.FromCtx(ctx) | |
| 443 | + | logEntry.Debug().Msg("blobs3: ListMultipart") | |
| 444 | + | input := &s3.ListMultipartUploadsInput{Bucket: aws.String(s.cfg.Bucket), Prefix: s.key(prefix)} | |
| 445 | + | for { | |
| 446 | + | o, e := s.client.ListMultipartUploads(ctx, input) | |
| 447 | + | if e != nil { | |
| 448 | + | return translate(e) | |
| 449 | + | } | |
| 450 | + | for _, v := range o.Uploads { | |
| 451 | + | if e = fn(ports.MultipartInfo{Key: strings.TrimPrefix(aws.ToString(v.Key), s.cfg.Prefix), ID: aws.ToString(v.UploadId), Started: aws.ToTime(v.Initiated)}); e != nil { | |
| 452 | + | return e | |
| 453 | + | } | |
| 454 | + | } | |
| 455 | + | if !aws.ToBool(o.IsTruncated) { | |
| 456 | + | return nil | |
| 457 | + | } | |
| 458 | + | input.KeyMarker = o.NextKeyMarker | |
| 459 | + | input.UploadIdMarker = o.NextUploadIdMarker | |
| 460 | + | } | |
| 461 | + | } | |
| @@ -0,0 +1,461 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Package blobs3 implements the blob port with the object, multipart and | |||
| 4 | + | // listing S3 APIs. Checksums are WhenRequired, credentials are explicit, and | |||
| 5 | + | // no bucket creation, policy, ACL, tagging or lifecycle API is ever called. | |||
| 6 | + | package blobs3 | |||
| 7 | + | ||||
| 8 | + | import ( | |||
| 9 | + | "bytes" | |||
| 10 | + | "context" | |||
| 11 | + | "crypto/sha256" | |||
| 12 | + | "encoding/hex" | |||
| 13 | + | "errors" | |||
| 14 | + | "fmt" | |||
| 15 | + | "io" | |||
| 16 | + | "net/http" | |||
| 17 | + | "sort" | |||
| 18 | + | "strconv" | |||
| 19 | + | "strings" | |||
| 20 | + | "sync" | |||
| 21 | + | "time" | |||
| 22 | + | ||||
| 23 | + | "bnema.dev/ermite/internal/domain" | |||
| 24 | + | "bnema.dev/ermite/internal/ports" | |||
| 25 | + | "github.com/aws/aws-sdk-go-v2/aws" | |||
| 26 | + | "github.com/aws/aws-sdk-go-v2/credentials" | |||
| 27 | + | "github.com/aws/aws-sdk-go-v2/service/s3" | |||
| 28 | + | "github.com/aws/aws-sdk-go-v2/service/s3/types" | |||
| 29 | + | "github.com/bnema/zerowrap" | |||
| 30 | + | ) | |||
| 31 | + | ||||
| 32 | + | // Store is a provider-independent S3 blob store. | |||
| 33 | + | type Store struct { | |||
| 34 | + | client *s3.Client | |||
| 35 | + | cfg Config | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | var _ ports.BlobStore = (*Store)(nil) | |||
| 39 | + | ||||
| 40 | + | // New builds a client without consulting ambient configuration or probing. | |||
| 41 | + | func New(ctx context.Context, cfg Config) (*Store, error) { | |||
| 42 | + | if e := ctx.Err(); e != nil { | |||
| 43 | + | return nil, e | |||
| 44 | + | } | |||
| 45 | + | if cfg.Bucket == "" || cfg.Region == "" || cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" { | |||
| 46 | + | return nil, fmt.Errorf("bucket, region and explicit credentials are required: %w", domain.ErrInvalid) | |||
| 47 | + | } | |||
| 48 | + | cfg.Prefix = strings.Trim(cfg.Prefix, "/") | |||
| 49 | + | if cfg.Prefix != "" { | |||
| 50 | + | cfg.Prefix += "/" | |||
| 51 | + | } | |||
| 52 | + | client := cfg.HTTPClient | |||
| 53 | + | if client == nil { | |||
| 54 | + | transport := http.DefaultTransport.(*http.Transport).Clone() | |||
| 55 | + | transport.IdleConnTimeout = 60 * time.Second | |||
| 56 | + | transport.ResponseHeaderTimeout = 60 * time.Second | |||
| 57 | + | client = &http.Client{Transport: transport} | |||
| 58 | + | } | |||
| 59 | + | ac := aws.Config{Region: cfg.Region, Credentials: credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""), HTTPClient: client, RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired, ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired, RetryMaxAttempts: 3} | |||
| 60 | + | c := s3.NewFromConfig(ac, func(o *s3.Options) { | |||
| 61 | + | if cfg.Endpoint != "" { | |||
| 62 | + | o.BaseEndpoint = aws.String(cfg.Endpoint) | |||
| 63 | + | } | |||
| 64 | + | o.UsePathStyle = cfg.ForcePathStyle | |||
| 65 | + | }) | |||
| 66 | + | return &Store{client: c, cfg: cfg}, nil | |||
| 67 | + | } | |||
| 68 | + | func valid(key string) error { | |||
| 69 | + | if !ports.ValidBlobKey(key) { | |||
| 70 | + | return domain.ErrInvalid | |||
| 71 | + | } | |||
| 72 | + | return nil | |||
| 73 | + | } | |||
| 74 | + | func (s *Store) key(key string) *string { return aws.String(s.cfg.Prefix + key) } | |||
| 75 | + | ||||
| 76 | + | type statusError interface{ HTTPStatusCode() int } | |||
| 77 | + | type codeError interface{ ErrorCode() string } | |||
| 78 | + | ||||
| 79 | + | func status(err error) int { | |||
| 80 | + | var e statusError | |||
| 81 | + | if errors.As(err, &e) { | |||
| 82 | + | return e.HTTPStatusCode() | |||
| 83 | + | } | |||
| 84 | + | return 0 | |||
| 85 | + | } | |||
| 86 | + | func translate(err error) error { | |||
| 87 | + | if err == nil { | |||
| 88 | + | return nil | |||
| 89 | + | } | |||
| 90 | + | var c codeError | |||
| 91 | + | if errors.As(err, &c) { | |||
| 92 | + | switch c.ErrorCode() { | |||
| 93 | + | case "InvalidRange": | |||
| 94 | + | return domain.ErrOutOfRange | |||
| 95 | + | case "NoSuchKey", "NotFound": | |||
| 96 | + | return domain.ErrNotFound | |||
| 97 | + | case "EntityTooSmall": | |||
| 98 | + | return domain.ErrInvalid | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | switch status(err) { | |||
| 102 | + | case 404: | |||
| 103 | + | return domain.ErrNotFound | |||
| 104 | + | case 416: | |||
| 105 | + | return domain.ErrOutOfRange | |||
| 106 | + | } | |||
| 107 | + | return err | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | type ctxReader struct { | |||
| 111 | + | ctx context.Context | |||
| 112 | + | r io.Reader | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func (r ctxReader) Read(p []byte) (int, error) { | |||
| 116 | + | if e := r.ctx.Err(); e != nil { | |||
| 117 | + | return 0, e | |||
| 118 | + | } | |||
| 119 | + | return r.r.Read(p) | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // One pooled buffer per in-flight upload bounds staging to BlobPartSize. | |||
| 123 | + | var buffers = sync.Pool{New: func() any { b := make([]byte, ports.BlobPartSize); return &b }} | |||
| 124 | + | ||||
| 125 | + | func exactEnd(ctx context.Context, r io.Reader) error { | |||
| 126 | + | var b [1]byte | |||
| 127 | + | n, e := io.ReadFull(ctxReader{ctx, r}, b[:]) | |||
| 128 | + | if n != 0 { | |||
| 129 | + | return domain.ErrInvalid | |||
| 130 | + | } | |||
| 131 | + | if e == io.EOF { | |||
| 132 | + | return nil | |||
| 133 | + | } | |||
| 134 | + | return e | |||
| 135 | + | } | |||
| 136 | + | func readPart(ctx context.Context, r io.Reader, b []byte) error { | |||
| 137 | + | _, e := io.ReadFull(ctxReader{ctx, r}, b) | |||
| 138 | + | if e == io.EOF || e == io.ErrUnexpectedEOF { | |||
| 139 | + | return domain.ErrInvalid | |||
| 140 | + | } | |||
| 141 | + | return e | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // Put uses signed, seekable bodies, multiparting objects larger than one buffer. | |||
| 145 | + | func (s *Store) Put(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) { | |||
| 146 | + | logEntry := zerowrap.FromCtx(ctx) | |||
| 147 | + | logEntry.Debug().Msg("blobs3: Put") | |||
| 148 | + | if e := valid(key); e != nil { | |||
| 149 | + | return ports.BlobInfo{}, e | |||
| 150 | + | } | |||
| 151 | + | if size < 0 || size > int64(ports.BlobPartSize)*ports.BlobMaxParts { | |||
| 152 | + | return ports.BlobInfo{}, domain.ErrInvalid | |||
| 153 | + | } | |||
| 154 | + | bp := buffers.Get().(*[]byte) | |||
| 155 | + | defer buffers.Put(bp) | |||
| 156 | + | h := sha256.New() | |||
| 157 | + | source := io.TeeReader(r, h) | |||
| 158 | + | if size <= ports.BlobPartSize { | |||
| 159 | + | b := (*bp)[:int(size)] | |||
| 160 | + | if e := readPart(ctx, source, b); e != nil { | |||
| 161 | + | return ports.BlobInfo{}, e | |||
| 162 | + | } | |||
| 163 | + | if e := exactEnd(ctx, r); e != nil { | |||
| 164 | + | return ports.BlobInfo{}, e | |||
| 165 | + | } | |||
| 166 | + | _, e := s.client.PutObject(ctx, &s3.PutObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), Body: bytes.NewReader(b), ContentLength: aws.Int64(size)}) | |||
| 167 | + | if e != nil { | |||
| 168 | + | return ports.BlobInfo{}, translate(e) | |||
| 169 | + | } | |||
| 170 | + | } else { | |||
| 171 | + | u, e := s.NewMultipart(ctx, key) | |||
| 172 | + | if e != nil { | |||
| 173 | + | return ports.BlobInfo{}, e | |||
| 174 | + | } | |||
| 175 | + | completed := false | |||
| 176 | + | defer func() { | |||
| 177 | + | if !completed { | |||
| 178 | + | cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second) | |||
| 179 | + | defer cancel() | |||
| 180 | + | _ = s.AbortMultipart(cleanup, key, u.ID()) | |||
| 181 | + | } | |||
| 182 | + | }() | |||
| 183 | + | remaining := size | |||
| 184 | + | for n := 1; remaining > 0; n++ { | |||
| 185 | + | count := min(remaining, int64(ports.BlobPartSize)) | |||
| 186 | + | b := (*bp)[:int(count)] | |||
| 187 | + | if e = readPart(ctx, source, b); e != nil { | |||
| 188 | + | return ports.BlobInfo{}, e | |||
| 189 | + | } | |||
| 190 | + | if e = u.UploadPart(ctx, n, bytes.NewReader(b), count); e != nil { | |||
| 191 | + | return ports.BlobInfo{}, e | |||
| 192 | + | } | |||
| 193 | + | remaining -= count | |||
| 194 | + | } | |||
| 195 | + | if e = exactEnd(ctx, r); e != nil { | |||
| 196 | + | return ports.BlobInfo{}, e | |||
| 197 | + | } | |||
| 198 | + | if _, e = u.Complete(ctx); e != nil { | |||
| 199 | + | return ports.BlobInfo{}, e | |||
| 200 | + | } | |||
| 201 | + | completed = true | |||
| 202 | + | } | |||
| 203 | + | log := zerowrap.FromCtx(ctx) | |||
| 204 | + | log.Debug().Str("key", key).Int64("size", size).Msg("blob stored") | |||
| 205 | + | return ports.BlobInfo{Key: key, Size: size, Digest: "sha256:" + hex.EncodeToString(h.Sum(nil)), ModTime: time.Now().UTC()}, nil | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | // Stat retrieves metadata without downloading content. | |||
| 209 | + | func (s *Store) Stat(ctx context.Context, key string) (ports.BlobInfo, error) { | |||
| 210 | + | if e := valid(key); e != nil { | |||
| 211 | + | return ports.BlobInfo{}, e | |||
| 212 | + | } | |||
| 213 | + | o, e := s.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)}) | |||
| 214 | + | if e != nil { | |||
| 215 | + | return ports.BlobInfo{}, translate(e) | |||
| 216 | + | } | |||
| 217 | + | return ports.BlobInfo{Key: key, Size: aws.ToInt64(o.ContentLength), ModTime: aws.ToTime(o.LastModified)}, nil | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | // Open streams a single range of an object. | |||
| 221 | + | // | |||
| 222 | + | //hexcheck:ignore suspicious-business-logic-in-adapter translates the port offset/length contract into an HTTP Range header; range rules belong to the port | |||
| 223 | + | func (s *Store) Open(ctx context.Context, key string, offset, length int64) (io.ReadCloser, ports.BlobInfo, error) { | |||
| 224 | + | if e := valid(key); e != nil { | |||
| 225 | + | return nil, ports.BlobInfo{}, e | |||
| 226 | + | } | |||
| 227 | + | if offset < 0 { | |||
| 228 | + | return nil, ports.BlobInfo{}, domain.ErrInvalid | |||
| 229 | + | } | |||
| 230 | + | if length == 0 { | |||
| 231 | + | info, e := s.Stat(ctx, key) | |||
| 232 | + | if e != nil { | |||
| 233 | + | return nil, info, e | |||
| 234 | + | } | |||
| 235 | + | if offset >= info.Size { | |||
| 236 | + | return nil, info, domain.ErrOutOfRange | |||
| 237 | + | } | |||
| 238 | + | return io.NopCloser(strings.NewReader("")), info, nil | |||
| 239 | + | } | |||
| 240 | + | rg := fmt.Sprintf("bytes=%d-", offset) | |||
| 241 | + | if length > 0 && length-1 <= int64(^uint64(0)>>1)-offset { | |||
| 242 | + | rg += strconv.FormatInt(offset+length-1, 10) | |||
| 243 | + | } | |||
| 244 | + | o, e := s.client.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), Range: aws.String(rg)}) | |||
| 245 | + | if e != nil { | |||
| 246 | + | return nil, ports.BlobInfo{}, translate(e) | |||
| 247 | + | } | |||
| 248 | + | size := aws.ToInt64(o.ContentLength) | |||
| 249 | + | if _, total, ok := strings.Cut(aws.ToString(o.ContentRange), "/"); ok { | |||
| 250 | + | if n, e := strconv.ParseInt(total, 10, 64); e == nil { | |||
| 251 | + | size = n | |||
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | return o.Body, ports.BlobInfo{Key: key, Size: size, ModTime: aws.ToTime(o.LastModified)}, nil | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | // Delete removes a key; absence is success. | |||
| 258 | + | func (s *Store) Delete(ctx context.Context, key string) error { | |||
| 259 | + | logEntry := zerowrap.FromCtx(ctx) | |||
| 260 | + | logEntry.Debug().Msg("blobs3: Delete") | |||
| 261 | + | if e := valid(key); e != nil { | |||
| 262 | + | return e | |||
| 263 | + | } | |||
| 264 | + | _, e := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)}) | |||
| 265 | + | if status(e) == 404 { | |||
| 266 | + | return nil | |||
| 267 | + | } | |||
| 268 | + | return translate(e) | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | // List visits matching objects lexicographically. | |||
| 272 | + | func (s *Store) List(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error { | |||
| 273 | + | logEntry := zerowrap.FromCtx(ctx) | |||
| 274 | + | logEntry.Debug().Msg("blobs3: List") | |||
| 275 | + | p := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{Bucket: aws.String(s.cfg.Bucket), Prefix: s.key(prefix)}) | |||
| 276 | + | for p.HasMorePages() { | |||
| 277 | + | o, e := p.NextPage(ctx) | |||
| 278 | + | if e != nil { | |||
| 279 | + | return translate(e) | |||
| 280 | + | } | |||
| 281 | + | for _, v := range o.Contents { | |||
| 282 | + | if e = fn(ports.BlobInfo{Key: strings.TrimPrefix(aws.ToString(v.Key), s.cfg.Prefix), Size: aws.ToInt64(v.Size), ModTime: aws.ToTime(v.LastModified)}); e != nil { | |||
| 283 | + | return e | |||
| 284 | + | } | |||
| 285 | + | } | |||
| 286 | + | } | |||
| 287 | + | return nil | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | // Check probes the existing bucket without modifying it. | |||
| 291 | + | func (s *Store) Check(ctx context.Context) error { | |||
| 292 | + | logEntry := zerowrap.FromCtx(ctx) | |||
| 293 | + | logEntry.Debug().Msg("blobs3: Check") | |||
| 294 | + | _, e := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.cfg.Bucket)}) | |||
| 295 | + | if status(e) == 403 { | |||
| 296 | + | _, e = s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: aws.String(s.cfg.Bucket), Prefix: aws.String(s.cfg.Prefix), MaxKeys: aws.Int32(1)}) | |||
| 297 | + | } | |||
| 298 | + | if e == nil { | |||
| 299 | + | return nil | |||
| 300 | + | } | |||
| 301 | + | where := s.cfg.Endpoint | |||
| 302 | + | if where == "" { | |||
| 303 | + | where = s.cfg.Region | |||
| 304 | + | } | |||
| 305 | + | return fmt.Errorf("bucket %q is not reachable at %s: %w", s.cfg.Bucket, where, e) | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | type part struct { | |||
| 309 | + | etag string | |||
| 310 | + | size int64 | |||
| 311 | + | } | |||
| 312 | + | type upload struct { | |||
| 313 | + | s *Store | |||
| 314 | + | key, id string | |||
| 315 | + | parts map[int]part | |||
| 316 | + | done bool | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | // NewMultipart initiates an object upload. | |||
| 320 | + | func (s *Store) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) { | |||
| 321 | + | logEntry := zerowrap.FromCtx(ctx) | |||
| 322 | + | logEntry.Debug().Msg("blobs3: NewMultipart") | |||
| 323 | + | if e := valid(key); e != nil { | |||
| 324 | + | return nil, e | |||
| 325 | + | } | |||
| 326 | + | o, e := s.client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)}) | |||
| 327 | + | if e != nil { | |||
| 328 | + | return nil, translate(e) | |||
| 329 | + | } | |||
| 330 | + | return &upload{s: s, key: key, id: aws.ToString(o.UploadId), parts: map[int]part{}}, nil | |||
| 331 | + | } | |||
| 332 | + | func (u *upload) ID() string { return u.id } | |||
| 333 | + | func (u *upload) UploadPart(ctx context.Context, n int, r io.Reader, size int64) error { | |||
| 334 | + | logEntry := zerowrap.FromCtx(ctx) | |||
| 335 | + | logEntry.Debug().Msg("blobs3: UploadPart") | |||
| 336 | + | if u.done || n < 1 || n > ports.BlobMaxParts || size < 0 || size > ports.BlobPartSize { | |||
| 337 | + | return domain.ErrInvalid | |||
| 338 | + | } | |||
| 339 | + | if e := ctx.Err(); e != nil { | |||
| 340 | + | return e | |||
| 341 | + | } | |||
| 342 | + | var body io.ReadSeeker | |||
| 343 | + | if seeker, ok := r.(io.ReadSeeker); ok { | |||
| 344 | + | start, e := seeker.Seek(0, io.SeekCurrent) | |||
| 345 | + | if e != nil { | |||
| 346 | + | return e | |||
| 347 | + | } | |||
| 348 | + | end, e := seeker.Seek(0, io.SeekEnd) | |||
| 349 | + | if e != nil { | |||
| 350 | + | return e | |||
| 351 | + | } | |||
| 352 | + | if _, e = seeker.Seek(start, io.SeekStart); e != nil { | |||
| 353 | + | return e | |||
| 354 | + | } | |||
| 355 | + | if end-start != size { | |||
| 356 | + | return domain.ErrInvalid | |||
| 357 | + | } | |||
| 358 | + | body = seeker | |||
| 359 | + | } else { | |||
| 360 | + | bp := buffers.Get().(*[]byte) | |||
| 361 | + | defer buffers.Put(bp) | |||
| 362 | + | b := (*bp)[:int(size)] | |||
| 363 | + | if e := readPart(ctx, r, b); e != nil { | |||
| 364 | + | return e | |||
| 365 | + | } | |||
| 366 | + | if e := exactEnd(ctx, r); e != nil { | |||
| 367 | + | return e | |||
| 368 | + | } | |||
| 369 | + | body = bytes.NewReader(b) | |||
| 370 | + | } | |||
| 371 | + | o, e := u.s.client.UploadPart(ctx, &s3.UploadPartInput{Bucket: aws.String(u.s.cfg.Bucket), Key: u.s.key(u.key), UploadId: aws.String(u.id), PartNumber: aws.Int32(int32(n)), Body: body, ContentLength: aws.Int64(size)}) | |||
| 372 | + | if e != nil { | |||
| 373 | + | return translate(e) | |||
| 374 | + | } | |||
| 375 | + | u.parts[n] = part{aws.ToString(o.ETag), size} | |||
| 376 | + | log := zerowrap.FromCtx(ctx) | |||
| 377 | + | log.Debug().Int("part", n).Msg("blob part uploaded") | |||
| 378 | + | return nil | |||
| 379 | + | } | |||
| 380 | + | func (u *upload) Complete(ctx context.Context) (ports.BlobInfo, error) { | |||
| 381 | + | logEntry := zerowrap.FromCtx(ctx) | |||
| 382 | + | logEntry.Debug().Msg("blobs3: Complete") | |||
| 383 | + | if u.done { | |||
| 384 | + | return ports.BlobInfo{}, domain.ErrInvalid | |||
| 385 | + | } | |||
| 386 | + | u.done = true | |||
| 387 | + | var nums []int | |||
| 388 | + | for n := range u.parts { | |||
| 389 | + | nums = append(nums, n) | |||
| 390 | + | } | |||
| 391 | + | sort.Ints(nums) | |||
| 392 | + | var parts []types.CompletedPart | |||
| 393 | + | var size int64 | |||
| 394 | + | invalid := len(nums) == 0 | |||
| 395 | + | for i, n := range nums { | |||
| 396 | + | p := u.parts[n] | |||
| 397 | + | if i < len(nums)-1 && p.size < ports.BlobMinPartSize { | |||
| 398 | + | invalid = true | |||
| 399 | + | } | |||
| 400 | + | size += p.size | |||
| 401 | + | parts = append(parts, types.CompletedPart{PartNumber: aws.Int32(int32(n)), ETag: aws.String(p.etag)}) | |||
| 402 | + | } | |||
| 403 | + | if invalid { | |||
| 404 | + | _ = u.s.AbortMultipart(ctx, u.key, u.id) | |||
| 405 | + | return ports.BlobInfo{}, domain.ErrInvalid | |||
| 406 | + | } | |||
| 407 | + | _, e := u.s.client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{Bucket: aws.String(u.s.cfg.Bucket), Key: u.s.key(u.key), UploadId: aws.String(u.id), MultipartUpload: &types.CompletedMultipartUpload{Parts: parts}}) | |||
| 408 | + | if e != nil { | |||
| 409 | + | cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second) | |||
| 410 | + | defer cancel() | |||
| 411 | + | _ = u.s.AbortMultipart(cleanup, u.key, u.id) | |||
| 412 | + | return ports.BlobInfo{}, translate(e) | |||
| 413 | + | } | |||
| 414 | + | return ports.BlobInfo{Key: u.key, Size: size, ModTime: time.Now().UTC()}, nil | |||
| 415 | + | } | |||
| 416 | + | func (u *upload) Abort(ctx context.Context) error { | |||
| 417 | + | logEntry := zerowrap.FromCtx(ctx) | |||
| 418 | + | logEntry.Debug().Msg("blobs3: Abort") | |||
| 419 | + | if u.done { | |||
| 420 | + | return domain.ErrInvalid | |||
| 421 | + | } | |||
| 422 | + | u.done = true | |||
| 423 | + | return u.s.AbortMultipart(ctx, u.key, u.id) | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | // AbortMultipart discards a listed upload. | |||
| 427 | + | func (s *Store) AbortMultipart(ctx context.Context, key, id string) error { | |||
| 428 | + | logEntry := zerowrap.FromCtx(ctx) | |||
| 429 | + | logEntry.Debug().Msg("blobs3: AbortMultipart") | |||
| 430 | + | if e := valid(key); e != nil { | |||
| 431 | + | return e | |||
| 432 | + | } | |||
| 433 | + | _, e := s.client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), UploadId: aws.String(id)}) | |||
| 434 | + | if status(e) == 404 { | |||
| 435 | + | return nil | |||
| 436 | + | } | |||
| 437 | + | return translate(e) | |||
| 438 | + | } | |||
| 439 | + | ||||
| 440 | + | // ListMultipart visits unfinished uploads matching prefix. | |||
| 441 | + | func (s *Store) ListMultipart(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error { | |||
| 442 | + | logEntry := zerowrap.FromCtx(ctx) | |||
| 443 | + | logEntry.Debug().Msg("blobs3: ListMultipart") | |||
| 444 | + | input := &s3.ListMultipartUploadsInput{Bucket: aws.String(s.cfg.Bucket), Prefix: s.key(prefix)} | |||
| 445 | + | for { | |||
| 446 | + | o, e := s.client.ListMultipartUploads(ctx, input) | |||
| 447 | + | if e != nil { | |||
| 448 | + | return translate(e) | |||
| 449 | + | } | |||
| 450 | + | for _, v := range o.Uploads { | |||
| 451 | + | if e = fn(ports.MultipartInfo{Key: strings.TrimPrefix(aws.ToString(v.Key), s.cfg.Prefix), ID: aws.ToString(v.UploadId), Started: aws.ToTime(v.Initiated)}); e != nil { | |||
| 452 | + | return e | |||
| 453 | + | } | |||
| 454 | + | } | |||
| 455 | + | if !aws.ToBool(o.IsTruncated) { | |||
| 456 | + | return nil | |||
| 457 | + | } | |||
| 458 | + | input.KeyMarker = o.NextKeyMarker | |||
| 459 | + | input.UploadIdMarker = o.NextUploadIdMarker | |||
| 460 | + | } | |||
| 461 | + | } | |||
A internal/adapters/blobs3/blobs3_test.go +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | package blobs3_test | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "net/http" | |
| 7 | + | "net/http/httptest" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/adapters/blobs3" | |
| 12 | + | "bnema.dev/ermite/internal/adapters/blobtest" | |
| 13 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | + | "github.com/johannesboyne/gofakes3" | |
| 15 | + | "github.com/johannesboyne/gofakes3/backend/s3mem" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func fake(t *testing.T) (*blobs3.Store, *s3mem.Backend) { | |
| 20 | + | t.Helper() | |
| 21 | + | backend := s3mem.New() | |
| 22 | + | require.NoError(t, backend.CreateBucket("b")) | |
| 23 | + | handler := gofakes3.New(backend).Server() | |
| 24 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 25 | + | t.Logf("S3 operation: %s %s", r.Method, r.URL.RequestURI()) | |
| 26 | + | for _, q := range []string{"policy", "acl", "tagging", "lifecycle", "versioning"} { | |
| 27 | + | if r.URL.Query().Has(q) { | |
| 28 | + | t.Errorf("unexpected admin operation %s", r.URL) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | if r.Method == http.MethodPut && strings.Trim(r.URL.Path, "/") == "b" { | |
| 32 | + | t.Error("unexpected bucket creation") | |
| 33 | + | } | |
| 34 | + | if r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/b/") { | |
| 35 | + | if r.Header.Get("X-Amz-Content-Sha256") == "UNSIGNED-PAYLOAD" || r.Header.Get("X-Amz-Content-Sha256") == "" { | |
| 36 | + | t.Error("payload must be signed") | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | handler.ServeHTTP(w, r) | |
| 40 | + | })) | |
| 41 | + | t.Cleanup(srv.Close) | |
| 42 | + | s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: "b", Prefix: "/pfx/", Endpoint: srv.URL, Region: "us-east-1", AccessKeyID: "test", SecretAccessKey: "test-secret", ForcePathStyle: true}) | |
| 43 | + | require.NoError(t, e) | |
| 44 | + | return s, backend | |
| 45 | + | } | |
| 46 | + | func TestContract(t *testing.T) { | |
| 47 | + | blobtest.Run(t, func(t *testing.T) ports.BlobStore { s, _ := fake(t); return s }) | |
| 48 | + | } | |
| 49 | + | func TestPrefix(t *testing.T) { | |
| 50 | + | s, b := fake(t) | |
| 51 | + | _, e := s.Put(context.Background(), "ci/one", strings.NewReader("x"), 1) | |
| 52 | + | require.NoError(t, e) | |
| 53 | + | objects, e := b.ListBucket("b", nil, gofakes3.ListBucketPage{}) | |
| 54 | + | require.NoError(t, e) | |
| 55 | + | require.Len(t, objects.Contents, 1) | |
| 56 | + | require.Equal(t, "pfx/ci/one", objects.Contents[0].Key) | |
| 57 | + | } | |
| 58 | + | func TestMissingBucket(t *testing.T) { | |
| 59 | + | s, b := fake(t) | |
| 60 | + | require.NoError(t, b.DeleteBucket("b")) | |
| 61 | + | e := s.Check(context.Background()) | |
| 62 | + | require.ErrorContains(t, e, `bucket "b" is not reachable at http://`) | |
| 63 | + | require.NotContains(t, e.Error(), "test-secret") | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestCheckDeniedHeadFallsBack(t *testing.T) { | |
| 67 | + | var listed bool | |
| 68 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 69 | + | if r.Method == http.MethodHead { | |
| 70 | + | w.WriteHeader(http.StatusForbidden) | |
| 71 | + | return | |
| 72 | + | } | |
| 73 | + | if r.Method != http.MethodGet || r.URL.Query().Get("list-type") != "2" || r.URL.Query().Get("max-keys") != "1" || r.URL.Query().Get("prefix") != "pfx/" { | |
| 74 | + | t.Errorf("unexpected probe: %s %s", r.Method, r.URL) | |
| 75 | + | } | |
| 76 | + | listed = true | |
| 77 | + | w.Header().Set("Content-Type", "application/xml") | |
| 78 | + | w.Write([]byte(`<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>b</Name><IsTruncated>false</IsTruncated></ListBucketResult>`)) | |
| 79 | + | })) | |
| 80 | + | defer srv.Close() | |
| 81 | + | s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: "b", Prefix: "pfx", Endpoint: srv.URL, Region: "test-region", AccessKeyID: "test", SecretAccessKey: "test-secret", ForcePathStyle: true}) | |
| 82 | + | require.NoError(t, e) | |
| 83 | + | require.NoError(t, s.Check(context.Background())) | |
| 84 | + | require.True(t, listed) | |
| 85 | + | } | |
| @@ -0,0 +1,85 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | package blobs3_test | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "net/http" | |||
| 7 | + | "net/http/httptest" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/ermite/internal/adapters/blobs3" | |||
| 12 | + | "bnema.dev/ermite/internal/adapters/blobtest" | |||
| 13 | + | "bnema.dev/ermite/internal/ports" | |||
| 14 | + | "github.com/johannesboyne/gofakes3" | |||
| 15 | + | "github.com/johannesboyne/gofakes3/backend/s3mem" | |||
| 16 | + | "github.com/stretchr/testify/require" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func fake(t *testing.T) (*blobs3.Store, *s3mem.Backend) { | |||
| 20 | + | t.Helper() | |||
| 21 | + | backend := s3mem.New() | |||
| 22 | + | require.NoError(t, backend.CreateBucket("b")) | |||
| 23 | + | handler := gofakes3.New(backend).Server() | |||
| 24 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 25 | + | t.Logf("S3 operation: %s %s", r.Method, r.URL.RequestURI()) | |||
| 26 | + | for _, q := range []string{"policy", "acl", "tagging", "lifecycle", "versioning"} { | |||
| 27 | + | if r.URL.Query().Has(q) { | |||
| 28 | + | t.Errorf("unexpected admin operation %s", r.URL) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | if r.Method == http.MethodPut && strings.Trim(r.URL.Path, "/") == "b" { | |||
| 32 | + | t.Error("unexpected bucket creation") | |||
| 33 | + | } | |||
| 34 | + | if r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/b/") { | |||
| 35 | + | if r.Header.Get("X-Amz-Content-Sha256") == "UNSIGNED-PAYLOAD" || r.Header.Get("X-Amz-Content-Sha256") == "" { | |||
| 36 | + | t.Error("payload must be signed") | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | handler.ServeHTTP(w, r) | |||
| 40 | + | })) | |||
| 41 | + | t.Cleanup(srv.Close) | |||
| 42 | + | s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: "b", Prefix: "/pfx/", Endpoint: srv.URL, Region: "us-east-1", AccessKeyID: "test", SecretAccessKey: "test-secret", ForcePathStyle: true}) | |||
| 43 | + | require.NoError(t, e) | |||
| 44 | + | return s, backend | |||
| 45 | + | } | |||
| 46 | + | func TestContract(t *testing.T) { | |||
| 47 | + | blobtest.Run(t, func(t *testing.T) ports.BlobStore { s, _ := fake(t); return s }) | |||
| 48 | + | } | |||
| 49 | + | func TestPrefix(t *testing.T) { | |||
| 50 | + | s, b := fake(t) | |||
| 51 | + | _, e := s.Put(context.Background(), "ci/one", strings.NewReader("x"), 1) | |||
| 52 | + | require.NoError(t, e) | |||
| 53 | + | objects, e := b.ListBucket("b", nil, gofakes3.ListBucketPage{}) | |||
| 54 | + | require.NoError(t, e) | |||
| 55 | + | require.Len(t, objects.Contents, 1) | |||
| 56 | + | require.Equal(t, "pfx/ci/one", objects.Contents[0].Key) | |||
| 57 | + | } | |||
| 58 | + | func TestMissingBucket(t *testing.T) { | |||
| 59 | + | s, b := fake(t) | |||
| 60 | + | require.NoError(t, b.DeleteBucket("b")) | |||
| 61 | + | e := s.Check(context.Background()) | |||
| 62 | + | require.ErrorContains(t, e, `bucket "b" is not reachable at http://`) | |||
| 63 | + | require.NotContains(t, e.Error(), "test-secret") | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestCheckDeniedHeadFallsBack(t *testing.T) { | |||
| 67 | + | var listed bool | |||
| 68 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 69 | + | if r.Method == http.MethodHead { | |||
| 70 | + | w.WriteHeader(http.StatusForbidden) | |||
| 71 | + | return | |||
| 72 | + | } | |||
| 73 | + | if r.Method != http.MethodGet || r.URL.Query().Get("list-type") != "2" || r.URL.Query().Get("max-keys") != "1" || r.URL.Query().Get("prefix") != "pfx/" { | |||
| 74 | + | t.Errorf("unexpected probe: %s %s", r.Method, r.URL) | |||
| 75 | + | } | |||
| 76 | + | listed = true | |||
| 77 | + | w.Header().Set("Content-Type", "application/xml") | |||
| 78 | + | w.Write([]byte(`<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>b</Name><IsTruncated>false</IsTruncated></ListBucketResult>`)) | |||
| 79 | + | })) | |||
| 80 | + | defer srv.Close() | |||
| 81 | + | s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: "b", Prefix: "pfx", Endpoint: srv.URL, Region: "test-region", AccessKeyID: "test", SecretAccessKey: "test-secret", ForcePathStyle: true}) | |||
| 82 | + | require.NoError(t, e) | |||
| 83 | + | require.NoError(t, s.Check(context.Background())) | |||
| 84 | + | require.True(t, listed) | |||
| 85 | + | } | |||
A internal/adapters/blobs3/config.go +13 -0
| @@ -0,0 +1,13 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | package blobs3 | |
| 3 | + | ||
| 4 | + | import "net/http" | |
| 5 | + | ||
| 6 | + | // Config supplies explicit credentials and endpoint settings; no ambient | |
| 7 | + | // credential source is consulted. | |
| 8 | + | type Config struct { | |
| 9 | + | Bucket, Prefix, Endpoint, Region, AccessKeyID, SecretAccessKey string | |
| 10 | + | ForcePathStyle bool | |
| 11 | + | // HTTPClient optionally overrides the transport (primarily for tests). | |
| 12 | + | HTTPClient *http.Client | |
| 13 | + | } | |
| @@ -0,0 +1,13 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | package blobs3 | |||
| 3 | + | ||||
| 4 | + | import "net/http" | |||
| 5 | + | ||||
| 6 | + | // Config supplies explicit credentials and endpoint settings; no ambient | |||
| 7 | + | // credential source is consulted. | |||
| 8 | + | type Config struct { | |||
| 9 | + | Bucket, Prefix, Endpoint, Region, AccessKeyID, SecretAccessKey string | |||
| 10 | + | ForcePathStyle bool | |||
| 11 | + | // HTTPClient optionally overrides the transport (primarily for tests). | |||
| 12 | + | HTTPClient *http.Client | |||
| 13 | + | } | |||
A internal/adapters/blobs3/integration_test.go +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | package blobs3_test | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "crypto/rand" | |
| 7 | + | "encoding/hex" | |
| 8 | + | "os" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/adapters/blobs3" | |
| 12 | + | "bnema.dev/ermite/internal/adapters/blobtest" | |
| 13 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestIntegration_Contract(t *testing.T) { | |
| 18 | + | get := func(k string) string { return os.Getenv("ERMITE_TEST_S3_" + k) } | |
| 19 | + | if get("BUCKET") == "" || get("ACCESS_KEY_ID") == "" || get("SECRET_ACCESS_KEY") == "" { | |
| 20 | + | t.Skip("set ERMITE_TEST_S3_* to run") | |
| 21 | + | } | |
| 22 | + | region := get("REGION") | |
| 23 | + | if region == "" { | |
| 24 | + | region = "us-east-1" | |
| 25 | + | } | |
| 26 | + | blobtest.Run(t, func(t *testing.T) ports.BlobStore { | |
| 27 | + | b := make([]byte, 16) | |
| 28 | + | _, e := rand.Read(b) | |
| 29 | + | require.NoError(t, e) | |
| 30 | + | s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: get("BUCKET"), Endpoint: get("ENDPOINT"), Region: region, AccessKeyID: get("ACCESS_KEY_ID"), SecretAccessKey: get("SECRET_ACCESS_KEY"), ForcePathStyle: get("FORCE_PATH_STYLE") == "1", Prefix: "ermite-test/" + hex.EncodeToString(b) + "/"}) | |
| 31 | + | require.NoError(t, e) | |
| 32 | + | t.Cleanup(func() { | |
| 33 | + | ctx := context.Background() | |
| 34 | + | require.NoError(t, s.List(ctx, "", func(i ports.BlobInfo) error { return s.Delete(ctx, i.Key) })) | |
| 35 | + | require.NoError(t, s.ListMultipart(ctx, "", func(i ports.MultipartInfo) error { return s.AbortMultipart(ctx, i.Key, i.ID) })) | |
| 36 | + | }) | |
| 37 | + | return s | |
| 38 | + | }) | |
| 39 | + | } | |
| @@ -0,0 +1,39 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | package blobs3_test | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "crypto/rand" | |||
| 7 | + | "encoding/hex" | |||
| 8 | + | "os" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/ermite/internal/adapters/blobs3" | |||
| 12 | + | "bnema.dev/ermite/internal/adapters/blobtest" | |||
| 13 | + | "bnema.dev/ermite/internal/ports" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestIntegration_Contract(t *testing.T) { | |||
| 18 | + | get := func(k string) string { return os.Getenv("ERMITE_TEST_S3_" + k) } | |||
| 19 | + | if get("BUCKET") == "" || get("ACCESS_KEY_ID") == "" || get("SECRET_ACCESS_KEY") == "" { | |||
| 20 | + | t.Skip("set ERMITE_TEST_S3_* to run") | |||
| 21 | + | } | |||
| 22 | + | region := get("REGION") | |||
| 23 | + | if region == "" { | |||
| 24 | + | region = "us-east-1" | |||
| 25 | + | } | |||
| 26 | + | blobtest.Run(t, func(t *testing.T) ports.BlobStore { | |||
| 27 | + | b := make([]byte, 16) | |||
| 28 | + | _, e := rand.Read(b) | |||
| 29 | + | require.NoError(t, e) | |||
| 30 | + | s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: get("BUCKET"), Endpoint: get("ENDPOINT"), Region: region, AccessKeyID: get("ACCESS_KEY_ID"), SecretAccessKey: get("SECRET_ACCESS_KEY"), ForcePathStyle: get("FORCE_PATH_STYLE") == "1", Prefix: "ermite-test/" + hex.EncodeToString(b) + "/"}) | |||
| 31 | + | require.NoError(t, e) | |||
| 32 | + | t.Cleanup(func() { | |||
| 33 | + | ctx := context.Background() | |||
| 34 | + | require.NoError(t, s.List(ctx, "", func(i ports.BlobInfo) error { return s.Delete(ctx, i.Key) })) | |||
| 35 | + | require.NoError(t, s.ListMultipart(ctx, "", func(i ports.MultipartInfo) error { return s.AbortMultipart(ctx, i.Key, i.ID) })) | |||
| 36 | + | }) | |||
| 37 | + | return s | |||
| 38 | + | }) | |||
| 39 | + | } | |||
A internal/adapters/blobtest/suite.go +234 -0
| @@ -0,0 +1,234 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package blobtest provides the shared BlobStore contract suite. | |
| 4 | + | package blobtest | |
| 5 | + | ||
| 6 | + | import ( | |
| 7 | + | "bytes" | |
| 8 | + | "context" | |
| 9 | + | "crypto/sha256" | |
| 10 | + | "encoding/hex" | |
| 11 | + | "errors" | |
| 12 | + | "io" | |
| 13 | + | "strings" | |
| 14 | + | "testing" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | "github.com/stretchr/testify/require" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // Run checks the blob contract against a fresh store for each subtest. | |
| 22 | + | func Run(t *testing.T, newStore func(*testing.T) ports.BlobStore) { | |
| 23 | + | ctx := context.Background() | |
| 24 | + | t.Run("roundtrip", func(t *testing.T) { | |
| 25 | + | s := newStore(t) | |
| 26 | + | require.NoError(t, s.Check(ctx)) | |
| 27 | + | data := []byte("abcdefghij") | |
| 28 | + | info, e := s.Put(ctx, "ci/roundtrip", bytes.NewReader(data), int64(len(data))) | |
| 29 | + | require.NoError(t, e) | |
| 30 | + | h := sha256.Sum256(data) | |
| 31 | + | require.Equal(t, "sha256:"+hex.EncodeToString(h[:]), info.Digest) | |
| 32 | + | info, e = s.Stat(ctx, "ci/roundtrip") | |
| 33 | + | require.NoError(t, e) | |
| 34 | + | require.EqualValues(t, len(data), info.Size) | |
| 35 | + | require.False(t, info.ModTime.IsZero()) | |
| 36 | + | for _, r := range []struct { | |
| 37 | + | off, len int64 | |
| 38 | + | want string | |
| 39 | + | }{{0, -1, "abcdefghij"}, {0, 3, "abc"}, {3, 4, "defg"}, {9, -1, "j"}, {3, 0, ""}} { | |
| 40 | + | rc, meta, e := s.Open(ctx, "ci/roundtrip", r.off, r.len) | |
| 41 | + | require.NoError(t, e) | |
| 42 | + | b, e := io.ReadAll(rc) | |
| 43 | + | require.NoError(t, e) | |
| 44 | + | require.NoError(t, rc.Close()) | |
| 45 | + | require.Equal(t, r.want, string(b)) | |
| 46 | + | require.EqualValues(t, 10, meta.Size) | |
| 47 | + | } | |
| 48 | + | for _, off := range []int64{10, 11} { | |
| 49 | + | _, _, e := s.Open(ctx, "ci/roundtrip", off, -1) | |
| 50 | + | require.ErrorIs(t, e, domain.ErrOutOfRange) | |
| 51 | + | } | |
| 52 | + | _, e = s.Put(ctx, "ci/roundtrip", strings.NewReader("new"), 3) | |
| 53 | + | require.NoError(t, e) | |
| 54 | + | rc, _, e := s.Open(ctx, "ci/roundtrip", 0, -1) | |
| 55 | + | require.NoError(t, e) | |
| 56 | + | b, e := io.ReadAll(rc) | |
| 57 | + | require.NoError(t, e) | |
| 58 | + | rc.Close() | |
| 59 | + | require.Equal(t, "new", string(b)) | |
| 60 | + | require.NoError(t, s.Delete(ctx, "ci/roundtrip")) | |
| 61 | + | require.NoError(t, s.Delete(ctx, "ci/roundtrip")) | |
| 62 | + | _, e = s.Stat(ctx, "ci/roundtrip") | |
| 63 | + | require.ErrorIs(t, e, domain.ErrNotFound) | |
| 64 | + | _, _, e = s.Open(ctx, "ci/absent", 0, -1) | |
| 65 | + | require.ErrorIs(t, e, domain.ErrNotFound) | |
| 66 | + | }) | |
| 67 | + | t.Run("list_and_reserved_names", func(t *testing.T) { | |
| 68 | + | s := newStore(t) | |
| 69 | + | keys := []string{"a.parts/b", "a/.tmp-object", "ci/z", "ci/a"} | |
| 70 | + | for _, k := range keys { | |
| 71 | + | _, e := s.Put(ctx, k, strings.NewReader(k), int64(len(k))) | |
| 72 | + | require.NoError(t, e) | |
| 73 | + | r, _, e := s.Open(ctx, k, 0, -1) | |
| 74 | + | require.NoError(t, e) | |
| 75 | + | b, e := io.ReadAll(r) | |
| 76 | + | r.Close() | |
| 77 | + | require.NoError(t, e) | |
| 78 | + | require.Equal(t, k, string(b)) | |
| 79 | + | } | |
| 80 | + | var got []string | |
| 81 | + | require.NoError(t, s.List(ctx, "", func(i ports.BlobInfo) error { got = append(got, i.Key); return nil })) | |
| 82 | + | require.Equal(t, []string{"a.parts/b", "a/.tmp-object", "ci/a", "ci/z"}, got) | |
| 83 | + | got = nil | |
| 84 | + | require.NoError(t, s.List(ctx, "ci/", func(i ports.BlobInfo) error { got = append(got, i.Key); return nil })) | |
| 85 | + | require.Equal(t, []string{"ci/a", "ci/z"}, got) | |
| 86 | + | stop := errors.New("stop") | |
| 87 | + | calls := 0 | |
| 88 | + | require.ErrorIs(t, s.List(ctx, "ci/", func(ports.BlobInfo) error { calls++; return stop }), stop) | |
| 89 | + | require.Equal(t, 1, calls) | |
| 90 | + | }) | |
| 91 | + | t.Run("invalid", func(t *testing.T) { | |
| 92 | + | s := newStore(t) | |
| 93 | + | for _, key := range []string{"", "/a", "a//b", "a/../b", "a/./b", "é", strings.Repeat("a", 513)} { | |
| 94 | + | _, e := s.Put(ctx, key, strings.NewReader("x"), 1) | |
| 95 | + | require.ErrorIs(t, e, domain.ErrInvalid) | |
| 96 | + | _, e = s.Stat(ctx, key) | |
| 97 | + | require.ErrorIs(t, e, domain.ErrInvalid) | |
| 98 | + | _, _, e = s.Open(ctx, key, 0, -1) | |
| 99 | + | require.ErrorIs(t, e, domain.ErrInvalid) | |
| 100 | + | require.ErrorIs(t, s.Delete(ctx, key), domain.ErrInvalid) | |
| 101 | + | _, e = s.NewMultipart(ctx, key) | |
| 102 | + | require.ErrorIs(t, e, domain.ErrInvalid) | |
| 103 | + | require.ErrorIs(t, s.AbortMultipart(ctx, key, "id"), domain.ErrInvalid) | |
| 104 | + | } | |
| 105 | + | _, e := s.Put(ctx, "ci/negative", strings.NewReader(""), -1) | |
| 106 | + | require.ErrorIs(t, e, domain.ErrInvalid) | |
| 107 | + | }) | |
| 108 | + | t.Run("multipart", func(t *testing.T) { | |
| 109 | + | s := newStore(t) | |
| 110 | + | u, e := s.NewMultipart(ctx, "ci/multi") | |
| 111 | + | require.NoError(t, e) | |
| 112 | + | var all []byte | |
| 113 | + | for n := 1; n <= 4; n++ { | |
| 114 | + | size := ports.BlobMinPartSize | |
| 115 | + | if n == 4 { | |
| 116 | + | size = 1024 | |
| 117 | + | } | |
| 118 | + | data := bytes.Repeat([]byte{byte(n)}, size) | |
| 119 | + | all = append(all, data...) | |
| 120 | + | require.NoError(t, u.UploadPart(ctx, n, bytes.NewReader(data), int64(size))) | |
| 121 | + | } | |
| 122 | + | info, e := u.Complete(ctx) | |
| 123 | + | require.NoError(t, e) | |
| 124 | + | require.Empty(t, info.Digest) | |
| 125 | + | r, _, e := s.Open(ctx, "ci/multi", 0, -1) | |
| 126 | + | require.NoError(t, e) | |
| 127 | + | b, e := io.ReadAll(r) | |
| 128 | + | r.Close() | |
| 129 | + | require.NoError(t, e) | |
| 130 | + | require.Equal(t, all, b) | |
| 131 | + | }) | |
| 132 | + | t.Run("abort", func(t *testing.T) { | |
| 133 | + | s := newStore(t) | |
| 134 | + | for _, byID := range []bool{false, true} { | |
| 135 | + | u, e := s.NewMultipart(ctx, "ci/abort") | |
| 136 | + | require.NoError(t, e) | |
| 137 | + | require.NoError(t, u.UploadPart(ctx, 1, strings.NewReader("x"), 1)) | |
| 138 | + | var uploads []ports.MultipartInfo | |
| 139 | + | require.NoError(t, s.ListMultipart(ctx, "ci/", func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil })) | |
| 140 | + | require.Len(t, uploads, 1) | |
| 141 | + | require.Equal(t, u.ID(), uploads[0].ID) | |
| 142 | + | require.Equal(t, "ci/abort", uploads[0].Key) | |
| 143 | + | require.False(t, uploads[0].Started.IsZero()) | |
| 144 | + | if byID { | |
| 145 | + | require.NoError(t, s.AbortMultipart(ctx, "ci/abort", u.ID())) | |
| 146 | + | } else { | |
| 147 | + | require.NoError(t, u.Abort(ctx)) | |
| 148 | + | } | |
| 149 | + | _, e = s.Stat(ctx, "ci/abort") | |
| 150 | + | require.ErrorIs(t, e, domain.ErrNotFound) | |
| 151 | + | uploads = nil | |
| 152 | + | require.NoError(t, s.ListMultipart(ctx, "", func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil })) | |
| 153 | + | require.Empty(t, uploads) | |
| 154 | + | } | |
| 155 | + | }) | |
| 156 | + | t.Run("undersized_part", func(t *testing.T) { | |
| 157 | + | s := newStore(t) | |
| 158 | + | u, e := s.NewMultipart(ctx, "ci/small") | |
| 159 | + | require.NoError(t, e) | |
| 160 | + | require.NoError(t, u.UploadPart(ctx, 1, strings.NewReader("x"), 1)) | |
| 161 | + | require.NoError(t, u.UploadPart(ctx, 2, strings.NewReader("y"), 1)) | |
| 162 | + | _, e = u.Complete(ctx) | |
| 163 | + | require.ErrorIs(t, e, domain.ErrInvalid) | |
| 164 | + | _, e = s.Stat(ctx, "ci/small") | |
| 165 | + | require.ErrorIs(t, e, domain.ErrNotFound) | |
| 166 | + | }) | |
| 167 | + | t.Run("failed_put", func(t *testing.T) { | |
| 168 | + | s := newStore(t) | |
| 169 | + | _, e := s.Put(ctx, "ci/fail", io.MultiReader(strings.NewReader("abc"), failReader{}), 10) | |
| 170 | + | require.Error(t, e) | |
| 171 | + | _, e = s.Stat(ctx, "ci/fail") | |
| 172 | + | require.ErrorIs(t, e, domain.ErrNotFound) | |
| 173 | + | for _, v := range []struct { | |
| 174 | + | data string | |
| 175 | + | size int64 | |
| 176 | + | }{{"a", 2}, {"abc", 2}} { | |
| 177 | + | _, e = s.Put(ctx, "ci/size", strings.NewReader(v.data), v.size) | |
| 178 | + | require.ErrorIs(t, e, domain.ErrInvalid) | |
| 179 | + | _, e = s.Stat(ctx, "ci/size") | |
| 180 | + | require.ErrorIs(t, e, domain.ErrNotFound) | |
| 181 | + | } | |
| 182 | + | }) | |
| 183 | + | t.Run("large_put", func(t *testing.T) { | |
| 184 | + | s := newStore(t) | |
| 185 | + | data := bytes.Repeat([]byte("large"), ports.BlobPartSize/5+100) | |
| 186 | + | info, e := s.Put(ctx, "ci/large", bytes.NewBuffer(data), int64(len(data))) | |
| 187 | + | require.NoError(t, e) | |
| 188 | + | h := sha256.Sum256(data) | |
| 189 | + | require.Equal(t, "sha256:"+hex.EncodeToString(h[:]), info.Digest) | |
| 190 | + | r, _, e := s.Open(ctx, "ci/large", 0, -1) | |
| 191 | + | require.NoError(t, e) | |
| 192 | + | got, e := io.ReadAll(r) | |
| 193 | + | require.NoError(t, e) | |
| 194 | + | require.NoError(t, r.Close()) | |
| 195 | + | require.Equal(t, data, got) | |
| 196 | + | for _, size := range []int64{int64(len(data) - 1), int64(len(data) + 1)} { | |
| 197 | + | _, e = s.Put(ctx, "ci/badlarge", bytes.NewBuffer(data), size) | |
| 198 | + | require.ErrorIs(t, e, domain.ErrInvalid) | |
| 199 | + | _, e = s.Stat(ctx, "ci/badlarge") | |
| 200 | + | require.ErrorIs(t, e, domain.ErrNotFound) | |
| 201 | + | } | |
| 202 | + | require.NoError(t, s.ListMultipart(ctx, "", func(ports.MultipartInfo) error { t.Error("unfinished upload after Put"); return nil })) | |
| 203 | + | }) | |
| 204 | + | ||
| 205 | + | t.Run("cancel_put", func(t *testing.T) { | |
| 206 | + | s := newStore(t) | |
| 207 | + | c, cancel := context.WithCancel(ctx) | |
| 208 | + | defer cancel() | |
| 209 | + | r := &cancelReader{cancel: cancel} | |
| 210 | + | _, e := s.Put(c, "ci/cancel", r, 1024) | |
| 211 | + | require.ErrorIs(t, e, context.Canceled) | |
| 212 | + | _, e = s.Stat(ctx, "ci/cancel") | |
| 213 | + | require.ErrorIs(t, e, domain.ErrNotFound) | |
| 214 | + | }) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | type failReader struct{} | |
| 218 | + | ||
| 219 | + | func (failReader) Read([]byte) (int, error) { return 0, errors.New("reader failed") } | |
| 220 | + | ||
| 221 | + | type cancelReader struct { | |
| 222 | + | cancel context.CancelFunc | |
| 223 | + | done bool | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func (r *cancelReader) Read(p []byte) (int, error) { | |
| 227 | + | if r.done { | |
| 228 | + | return 0, context.Canceled | |
| 229 | + | } | |
| 230 | + | r.done = true | |
| 231 | + | r.cancel() | |
| 232 | + | p[0] = 'x' | |
| 233 | + | return 1, nil | |
| 234 | + | } | |
| @@ -0,0 +1,234 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Package blobtest provides the shared BlobStore contract suite. | |||
| 4 | + | package blobtest | |||
| 5 | + | ||||
| 6 | + | import ( | |||
| 7 | + | "bytes" | |||
| 8 | + | "context" | |||
| 9 | + | "crypto/sha256" | |||
| 10 | + | "encoding/hex" | |||
| 11 | + | "errors" | |||
| 12 | + | "io" | |||
| 13 | + | "strings" | |||
| 14 | + | "testing" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite/internal/domain" | |||
| 17 | + | "bnema.dev/ermite/internal/ports" | |||
| 18 | + | "github.com/stretchr/testify/require" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // Run checks the blob contract against a fresh store for each subtest. | |||
| 22 | + | func Run(t *testing.T, newStore func(*testing.T) ports.BlobStore) { | |||
| 23 | + | ctx := context.Background() | |||
| 24 | + | t.Run("roundtrip", func(t *testing.T) { | |||
| 25 | + | s := newStore(t) | |||
| 26 | + | require.NoError(t, s.Check(ctx)) | |||
| 27 | + | data := []byte("abcdefghij") | |||
| 28 | + | info, e := s.Put(ctx, "ci/roundtrip", bytes.NewReader(data), int64(len(data))) | |||
| 29 | + | require.NoError(t, e) | |||
| 30 | + | h := sha256.Sum256(data) | |||
| 31 | + | require.Equal(t, "sha256:"+hex.EncodeToString(h[:]), info.Digest) | |||
| 32 | + | info, e = s.Stat(ctx, "ci/roundtrip") | |||
| 33 | + | require.NoError(t, e) | |||
| 34 | + | require.EqualValues(t, len(data), info.Size) | |||
| 35 | + | require.False(t, info.ModTime.IsZero()) | |||
| 36 | + | for _, r := range []struct { | |||
| 37 | + | off, len int64 | |||
| 38 | + | want string | |||
| 39 | + | }{{0, -1, "abcdefghij"}, {0, 3, "abc"}, {3, 4, "defg"}, {9, -1, "j"}, {3, 0, ""}} { | |||
| 40 | + | rc, meta, e := s.Open(ctx, "ci/roundtrip", r.off, r.len) | |||
| 41 | + | require.NoError(t, e) | |||
| 42 | + | b, e := io.ReadAll(rc) | |||
| 43 | + | require.NoError(t, e) | |||
| 44 | + | require.NoError(t, rc.Close()) | |||
| 45 | + | require.Equal(t, r.want, string(b)) | |||
| 46 | + | require.EqualValues(t, 10, meta.Size) | |||
| 47 | + | } | |||
| 48 | + | for _, off := range []int64{10, 11} { | |||
| 49 | + | _, _, e := s.Open(ctx, "ci/roundtrip", off, -1) | |||
| 50 | + | require.ErrorIs(t, e, domain.ErrOutOfRange) | |||
| 51 | + | } | |||
| 52 | + | _, e = s.Put(ctx, "ci/roundtrip", strings.NewReader("new"), 3) | |||
| 53 | + | require.NoError(t, e) | |||
| 54 | + | rc, _, e := s.Open(ctx, "ci/roundtrip", 0, -1) | |||
| 55 | + | require.NoError(t, e) | |||
| 56 | + | b, e := io.ReadAll(rc) | |||
| 57 | + | require.NoError(t, e) | |||
| 58 | + | rc.Close() | |||
| 59 | + | require.Equal(t, "new", string(b)) | |||
| 60 | + | require.NoError(t, s.Delete(ctx, "ci/roundtrip")) | |||
| 61 | + | require.NoError(t, s.Delete(ctx, "ci/roundtrip")) | |||
| 62 | + | _, e = s.Stat(ctx, "ci/roundtrip") | |||
| 63 | + | require.ErrorIs(t, e, domain.ErrNotFound) | |||
| 64 | + | _, _, e = s.Open(ctx, "ci/absent", 0, -1) | |||
| 65 | + | require.ErrorIs(t, e, domain.ErrNotFound) | |||
| 66 | + | }) | |||
| 67 | + | t.Run("list_and_reserved_names", func(t *testing.T) { | |||
| 68 | + | s := newStore(t) | |||
| 69 | + | keys := []string{"a.parts/b", "a/.tmp-object", "ci/z", "ci/a"} | |||
| 70 | + | for _, k := range keys { | |||
| 71 | + | _, e := s.Put(ctx, k, strings.NewReader(k), int64(len(k))) | |||
| 72 | + | require.NoError(t, e) | |||
| 73 | + | r, _, e := s.Open(ctx, k, 0, -1) | |||
| 74 | + | require.NoError(t, e) | |||
| 75 | + | b, e := io.ReadAll(r) | |||
| 76 | + | r.Close() | |||
| 77 | + | require.NoError(t, e) | |||
| 78 | + | require.Equal(t, k, string(b)) | |||
| 79 | + | } | |||
| 80 | + | var got []string | |||
| 81 | + | require.NoError(t, s.List(ctx, "", func(i ports.BlobInfo) error { got = append(got, i.Key); return nil })) | |||
| 82 | + | require.Equal(t, []string{"a.parts/b", "a/.tmp-object", "ci/a", "ci/z"}, got) | |||
| 83 | + | got = nil | |||
| 84 | + | require.NoError(t, s.List(ctx, "ci/", func(i ports.BlobInfo) error { got = append(got, i.Key); return nil })) | |||
| 85 | + | require.Equal(t, []string{"ci/a", "ci/z"}, got) | |||
| 86 | + | stop := errors.New("stop") | |||
| 87 | + | calls := 0 | |||
| 88 | + | require.ErrorIs(t, s.List(ctx, "ci/", func(ports.BlobInfo) error { calls++; return stop }), stop) | |||
| 89 | + | require.Equal(t, 1, calls) | |||
| 90 | + | }) | |||
| 91 | + | t.Run("invalid", func(t *testing.T) { | |||
| 92 | + | s := newStore(t) | |||
| 93 | + | for _, key := range []string{"", "/a", "a//b", "a/../b", "a/./b", "é", strings.Repeat("a", 513)} { | |||
| 94 | + | _, e := s.Put(ctx, key, strings.NewReader("x"), 1) | |||
| 95 | + | require.ErrorIs(t, e, domain.ErrInvalid) | |||
| 96 | + | _, e = s.Stat(ctx, key) | |||
| 97 | + | require.ErrorIs(t, e, domain.ErrInvalid) | |||
| 98 | + | _, _, e = s.Open(ctx, key, 0, -1) | |||
| 99 | + | require.ErrorIs(t, e, domain.ErrInvalid) | |||
| 100 | + | require.ErrorIs(t, s.Delete(ctx, key), domain.ErrInvalid) | |||
| 101 | + | _, e = s.NewMultipart(ctx, key) | |||
| 102 | + | require.ErrorIs(t, e, domain.ErrInvalid) | |||
| 103 | + | require.ErrorIs(t, s.AbortMultipart(ctx, key, "id"), domain.ErrInvalid) | |||
| 104 | + | } | |||
| 105 | + | _, e := s.Put(ctx, "ci/negative", strings.NewReader(""), -1) | |||
| 106 | + | require.ErrorIs(t, e, domain.ErrInvalid) | |||
| 107 | + | }) | |||
| 108 | + | t.Run("multipart", func(t *testing.T) { | |||
| 109 | + | s := newStore(t) | |||
| 110 | + | u, e := s.NewMultipart(ctx, "ci/multi") | |||
| 111 | + | require.NoError(t, e) | |||
| 112 | + | var all []byte | |||
| 113 | + | for n := 1; n <= 4; n++ { | |||
| 114 | + | size := ports.BlobMinPartSize | |||
| 115 | + | if n == 4 { | |||
| 116 | + | size = 1024 | |||
| 117 | + | } | |||
| 118 | + | data := bytes.Repeat([]byte{byte(n)}, size) | |||
| 119 | + | all = append(all, data...) | |||
| 120 | + | require.NoError(t, u.UploadPart(ctx, n, bytes.NewReader(data), int64(size))) | |||
| 121 | + | } | |||
| 122 | + | info, e := u.Complete(ctx) | |||
| 123 | + | require.NoError(t, e) | |||
| 124 | + | require.Empty(t, info.Digest) | |||
| 125 | + | r, _, e := s.Open(ctx, "ci/multi", 0, -1) | |||
| 126 | + | require.NoError(t, e) | |||
| 127 | + | b, e := io.ReadAll(r) | |||
| 128 | + | r.Close() | |||
| 129 | + | require.NoError(t, e) | |||
| 130 | + | require.Equal(t, all, b) | |||
| 131 | + | }) | |||
| 132 | + | t.Run("abort", func(t *testing.T) { | |||
| 133 | + | s := newStore(t) | |||
| 134 | + | for _, byID := range []bool{false, true} { | |||
| 135 | + | u, e := s.NewMultipart(ctx, "ci/abort") | |||
| 136 | + | require.NoError(t, e) | |||
| 137 | + | require.NoError(t, u.UploadPart(ctx, 1, strings.NewReader("x"), 1)) | |||
| 138 | + | var uploads []ports.MultipartInfo | |||
| 139 | + | require.NoError(t, s.ListMultipart(ctx, "ci/", func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil })) | |||
| 140 | + | require.Len(t, uploads, 1) | |||
| 141 | + | require.Equal(t, u.ID(), uploads[0].ID) | |||
| 142 | + | require.Equal(t, "ci/abort", uploads[0].Key) | |||
| 143 | + | require.False(t, uploads[0].Started.IsZero()) | |||
| 144 | + | if byID { | |||
| 145 | + | require.NoError(t, s.AbortMultipart(ctx, "ci/abort", u.ID())) | |||
| 146 | + | } else { | |||
| 147 | + | require.NoError(t, u.Abort(ctx)) | |||
| 148 | + | } | |||
| 149 | + | _, e = s.Stat(ctx, "ci/abort") | |||
| 150 | + | require.ErrorIs(t, e, domain.ErrNotFound) | |||
| 151 | + | uploads = nil | |||
| 152 | + | require.NoError(t, s.ListMultipart(ctx, "", func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil })) | |||
| 153 | + | require.Empty(t, uploads) | |||
| 154 | + | } | |||
| 155 | + | }) | |||
| 156 | + | t.Run("undersized_part", func(t *testing.T) { | |||
| 157 | + | s := newStore(t) | |||
| 158 | + | u, e := s.NewMultipart(ctx, "ci/small") | |||
| 159 | + | require.NoError(t, e) | |||
| 160 | + | require.NoError(t, u.UploadPart(ctx, 1, strings.NewReader("x"), 1)) | |||
| 161 | + | require.NoError(t, u.UploadPart(ctx, 2, strings.NewReader("y"), 1)) | |||
| 162 | + | _, e = u.Complete(ctx) | |||
| 163 | + | require.ErrorIs(t, e, domain.ErrInvalid) | |||
| 164 | + | _, e = s.Stat(ctx, "ci/small") | |||
| 165 | + | require.ErrorIs(t, e, domain.ErrNotFound) | |||
| 166 | + | }) | |||
| 167 | + | t.Run("failed_put", func(t *testing.T) { | |||
| 168 | + | s := newStore(t) | |||
| 169 | + | _, e := s.Put(ctx, "ci/fail", io.MultiReader(strings.NewReader("abc"), failReader{}), 10) | |||
| 170 | + | require.Error(t, e) | |||
| 171 | + | _, e = s.Stat(ctx, "ci/fail") | |||
| 172 | + | require.ErrorIs(t, e, domain.ErrNotFound) | |||
| 173 | + | for _, v := range []struct { | |||
| 174 | + | data string | |||
| 175 | + | size int64 | |||
| 176 | + | }{{"a", 2}, {"abc", 2}} { | |||
| 177 | + | _, e = s.Put(ctx, "ci/size", strings.NewReader(v.data), v.size) | |||
| 178 | + | require.ErrorIs(t, e, domain.ErrInvalid) | |||
| 179 | + | _, e = s.Stat(ctx, "ci/size") | |||
| 180 | + | require.ErrorIs(t, e, domain.ErrNotFound) | |||
| 181 | + | } | |||
| 182 | + | }) | |||
| 183 | + | t.Run("large_put", func(t *testing.T) { | |||
| 184 | + | s := newStore(t) | |||
| 185 | + | data := bytes.Repeat([]byte("large"), ports.BlobPartSize/5+100) | |||
| 186 | + | info, e := s.Put(ctx, "ci/large", bytes.NewBuffer(data), int64(len(data))) | |||
| 187 | + | require.NoError(t, e) | |||
| 188 | + | h := sha256.Sum256(data) | |||
| 189 | + | require.Equal(t, "sha256:"+hex.EncodeToString(h[:]), info.Digest) | |||
| 190 | + | r, _, e := s.Open(ctx, "ci/large", 0, -1) | |||
| 191 | + | require.NoError(t, e) | |||
| 192 | + | got, e := io.ReadAll(r) | |||
| 193 | + | require.NoError(t, e) | |||
| 194 | + | require.NoError(t, r.Close()) | |||
| 195 | + | require.Equal(t, data, got) | |||
| 196 | + | for _, size := range []int64{int64(len(data) - 1), int64(len(data) + 1)} { | |||
| 197 | + | _, e = s.Put(ctx, "ci/badlarge", bytes.NewBuffer(data), size) | |||
| 198 | + | require.ErrorIs(t, e, domain.ErrInvalid) | |||
| 199 | + | _, e = s.Stat(ctx, "ci/badlarge") | |||
| 200 | + | require.ErrorIs(t, e, domain.ErrNotFound) | |||
| 201 | + | } | |||
| 202 | + | require.NoError(t, s.ListMultipart(ctx, "", func(ports.MultipartInfo) error { t.Error("unfinished upload after Put"); return nil })) | |||
| 203 | + | }) | |||
| 204 | + | ||||
| 205 | + | t.Run("cancel_put", func(t *testing.T) { | |||
| 206 | + | s := newStore(t) | |||
| 207 | + | c, cancel := context.WithCancel(ctx) | |||
| 208 | + | defer cancel() | |||
| 209 | + | r := &cancelReader{cancel: cancel} | |||
| 210 | + | _, e := s.Put(c, "ci/cancel", r, 1024) | |||
| 211 | + | require.ErrorIs(t, e, context.Canceled) | |||
| 212 | + | _, e = s.Stat(ctx, "ci/cancel") | |||
| 213 | + | require.ErrorIs(t, e, domain.ErrNotFound) | |||
| 214 | + | }) | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | type failReader struct{} | |||
| 218 | + | ||||
| 219 | + | func (failReader) Read([]byte) (int, error) { return 0, errors.New("reader failed") } | |||
| 220 | + | ||||
| 221 | + | type cancelReader struct { | |||
| 222 | + | cancel context.CancelFunc | |||
| 223 | + | done bool | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func (r *cancelReader) Read(p []byte) (int, error) { | |||
| 227 | + | if r.done { | |||
| 228 | + | return 0, context.Canceled | |||
| 229 | + | } | |||
| 230 | + | r.done = true | |||
| 231 | + | r.cancel() | |||
| 232 | + | p[0] = 'x' | |||
| 233 | + | return 1, nil | |||
| 234 | + | } | |||
A internal/ports/blob.go +72 -0
| @@ -0,0 +1,72 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package ports | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "io" | |
| 8 | + | "strings" | |
| 9 | + | "time" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // BlobStore keeps the contents of release assets and CI artifacts, never | |
| 13 | + | // their metadata. Keys are built by the use cases from ids only ("a/b/c", | |
| 14 | + | // ASCII, no empty segment, no ".", no ".." segment, no leading "/", ≤ 512 | |
| 15 | + | // bytes); an adapter rejects any other key with domain.ErrInvalid. | |
| 16 | + | // *blobdisk.Store and *blobs3.Store implement it. | |
| 17 | + | type BlobStore interface { | |
| 18 | + | Put(ctx context.Context, key string, r io.Reader, size int64) (BlobInfo, error) // size ≥ 0 required; replaces; a failure leaves no object | |
| 19 | + | Open(ctx context.Context, key string, offset, length int64) (io.ReadCloser, BlobInfo, error) // length < 0: to the end; offset ≥ size → domain.ErrOutOfRange | |
| 20 | + | Stat(ctx context.Context, key string) (BlobInfo, error) | |
| 21 | + | Delete(ctx context.Context, key string) error // absent: nil | |
| 22 | + | List(ctx context.Context, prefix string, fn func(BlobInfo) error) error // lexicographic; fn error stops and is returned | |
| 23 | + | NewMultipart(ctx context.Context, key string) (MultipartUpload, error) | |
| 24 | + | ListMultipart(ctx context.Context, prefix string, fn func(MultipartInfo) error) error | |
| 25 | + | AbortMultipart(ctx context.Context, key, id string) error // for GC of uploads listed by ListMultipart | |
| 26 | + | Check(ctx context.Context) error // startup probe; never creates anything | |
| 27 | + | } | |
| 28 | + | type BlobInfo struct { | |
| 29 | + | Key string | |
| 30 | + | Size int64 | |
| 31 | + | Digest string | |
| 32 | + | ModTime time.Time | |
| 33 | + | } // Digest "sha256:<hex>" from Put, "" from Stat/List/Open on S3 | |
| 34 | + | type MultipartInfo struct { | |
| 35 | + | Key, ID string | |
| 36 | + | Started time.Time | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // MultipartUpload uploads one object in parts of BlobMinPartSize or more | |
| 40 | + | // (the last may be smaller), at most BlobMaxParts, numbered from 1 in the | |
| 41 | + | // order given to Complete. Abort discards everything; Complete or Abort | |
| 42 | + | // must be called exactly once. | |
| 43 | + | type MultipartUpload interface { | |
| 44 | + | ID() string | |
| 45 | + | UploadPart(ctx context.Context, n int, r io.Reader, size int64) error | |
| 46 | + | Complete(ctx context.Context) (BlobInfo, error) // parts in number order; Digest "" (the caller hashes) | |
| 47 | + | Abort(ctx context.Context) error | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | const ( | |
| 51 | + | BlobMinPartSize = 5 << 20 | |
| 52 | + | BlobPartSize = 8 << 20 | |
| 53 | + | BlobMaxParts = 10_000 | |
| 54 | + | ) | |
| 55 | + | ||
| 56 | + | // ValidBlobKey reports whether key follows the rule above. | |
| 57 | + | func ValidBlobKey(key string) bool { | |
| 58 | + | if len(key) == 0 || len(key) > 512 { | |
| 59 | + | return false | |
| 60 | + | } | |
| 61 | + | for _, c := range []byte(key) { | |
| 62 | + | if c > 127 { | |
| 63 | + | return false | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | for _, segment := range strings.Split(key, "/") { | |
| 67 | + | if segment == "" || segment == "." || segment == ".." { | |
| 68 | + | return false | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | return true | |
| 72 | + | } | |
| @@ -0,0 +1,72 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package ports | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "io" | |||
| 8 | + | "strings" | |||
| 9 | + | "time" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // BlobStore keeps the contents of release assets and CI artifacts, never | |||
| 13 | + | // their metadata. Keys are built by the use cases from ids only ("a/b/c", | |||
| 14 | + | // ASCII, no empty segment, no ".", no ".." segment, no leading "/", ≤ 512 | |||
| 15 | + | // bytes); an adapter rejects any other key with domain.ErrInvalid. | |||
| 16 | + | // *blobdisk.Store and *blobs3.Store implement it. | |||
| 17 | + | type BlobStore interface { | |||
| 18 | + | Put(ctx context.Context, key string, r io.Reader, size int64) (BlobInfo, error) // size ≥ 0 required; replaces; a failure leaves no object | |||
| 19 | + | Open(ctx context.Context, key string, offset, length int64) (io.ReadCloser, BlobInfo, error) // length < 0: to the end; offset ≥ size → domain.ErrOutOfRange | |||
| 20 | + | Stat(ctx context.Context, key string) (BlobInfo, error) | |||
| 21 | + | Delete(ctx context.Context, key string) error // absent: nil | |||
| 22 | + | List(ctx context.Context, prefix string, fn func(BlobInfo) error) error // lexicographic; fn error stops and is returned | |||
| 23 | + | NewMultipart(ctx context.Context, key string) (MultipartUpload, error) | |||
| 24 | + | ListMultipart(ctx context.Context, prefix string, fn func(MultipartInfo) error) error | |||
| 25 | + | AbortMultipart(ctx context.Context, key, id string) error // for GC of uploads listed by ListMultipart | |||
| 26 | + | Check(ctx context.Context) error // startup probe; never creates anything | |||
| 27 | + | } | |||
| 28 | + | type BlobInfo struct { | |||
| 29 | + | Key string | |||
| 30 | + | Size int64 | |||
| 31 | + | Digest string | |||
| 32 | + | ModTime time.Time | |||
| 33 | + | } // Digest "sha256:<hex>" from Put, "" from Stat/List/Open on S3 | |||
| 34 | + | type MultipartInfo struct { | |||
| 35 | + | Key, ID string | |||
| 36 | + | Started time.Time | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // MultipartUpload uploads one object in parts of BlobMinPartSize or more | |||
| 40 | + | // (the last may be smaller), at most BlobMaxParts, numbered from 1 in the | |||
| 41 | + | // order given to Complete. Abort discards everything; Complete or Abort | |||
| 42 | + | // must be called exactly once. | |||
| 43 | + | type MultipartUpload interface { | |||
| 44 | + | ID() string | |||
| 45 | + | UploadPart(ctx context.Context, n int, r io.Reader, size int64) error | |||
| 46 | + | Complete(ctx context.Context) (BlobInfo, error) // parts in number order; Digest "" (the caller hashes) | |||
| 47 | + | Abort(ctx context.Context) error | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | const ( | |||
| 51 | + | BlobMinPartSize = 5 << 20 | |||
| 52 | + | BlobPartSize = 8 << 20 | |||
| 53 | + | BlobMaxParts = 10_000 | |||
| 54 | + | ) | |||
| 55 | + | ||||
| 56 | + | // ValidBlobKey reports whether key follows the rule above. | |||
| 57 | + | func ValidBlobKey(key string) bool { | |||
| 58 | + | if len(key) == 0 || len(key) > 512 { | |||
| 59 | + | return false | |||
| 60 | + | } | |||
| 61 | + | for _, c := range []byte(key) { | |||
| 62 | + | if c > 127 { | |||
| 63 | + | return false | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | for _, segment := range strings.Split(key, "/") { | |||
| 67 | + | if segment == "" || segment == "." || segment == ".." { | |||
| 68 | + | return false | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | return true | |||
| 72 | + | } | |||
A internal/ports/blob_test.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | package ports_test | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "bnema.dev/ermite/internal/ports" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestValidBlobKey(t *testing.T) { | |
| 13 | + | for _, v := range []struct { | |
| 14 | + | key string | |
| 15 | + | ok bool | |
| 16 | + | }{{"ci/00000000000000000001/x", true}, {"a/.tmp-object", true}, {"a.parts/b", true}, {"", false}, {"/a", false}, {"a//b", false}, {"a/../b", false}, {"a/./b", false}, {"é", false}, {strings.Repeat("a", 513), false}} { | |
| 17 | + | t.Run(v.key, func(t *testing.T) { require.Equal(t, v.ok, ports.ValidBlobKey(v.key)) }) | |
| 18 | + | } | |
| 19 | + | } | |
| @@ -0,0 +1,19 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | package ports_test | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "bnema.dev/ermite/internal/ports" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestValidBlobKey(t *testing.T) { | |||
| 13 | + | for _, v := range []struct { | |||
| 14 | + | key string | |||
| 15 | + | ok bool | |||
| 16 | + | }{{"ci/00000000000000000001/x", true}, {"a/.tmp-object", true}, {"a.parts/b", true}, {"", false}, {"/a", false}, {"a//b", false}, {"a/../b", false}, {"a/./b", false}, {"é", false}, {strings.Repeat("a", 513), false}} { | |||
| 17 | + | t.Run(v.key, func(t *testing.T) { require.Equal(t, v.ok, ports.ValidBlobKey(v.key)) }) | |||
| 18 | + | } | |||
| 19 | + | } | |||
A internal/ports/mocks/BlobStore.go +644 -0
| @@ -0,0 +1,644 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "io" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewBlobStore creates a new instance of BlobStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | + | // The first argument is typically a *testing.T value. | |
| 17 | + | func NewBlobStore(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *BlobStore { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &BlobStore{} | |
| 26 | + | mock.Mock.Test(t) | |
| 27 | + | ||
| 28 | + | t.Cleanup(func() { | |
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 30 | + | helper.Helper() | |
| 31 | + | } | |
| 32 | + | mock.AssertExpectations(t) | |
| 33 | + | }) | |
| 34 | + | ||
| 35 | + | return mock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // BlobStore is an autogenerated mock type for the BlobStore type | |
| 39 | + | type BlobStore struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type BlobStore_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *BlobStore) EXPECT() *BlobStore_Expecter { | |
| 48 | + | return &BlobStore_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // AbortMultipart provides a mock function for the type BlobStore | |
| 52 | + | func (_mock *BlobStore) AbortMultipart(ctx context.Context, key string, id string) error { | |
| 53 | + | ret := _mock.Called(ctx, key, id) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for AbortMultipart") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { | |
| 61 | + | r0 = returnFunc(ctx, key, id) | |
| 62 | + | } else { | |
| 63 | + | r0 = ret.Error(0) | |
| 64 | + | } | |
| 65 | + | return r0 | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // BlobStore_AbortMultipart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AbortMultipart' | |
| 69 | + | type BlobStore_AbortMultipart_Call struct { | |
| 70 | + | *mock.Call | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // AbortMultipart is a helper method to define mock.On call | |
| 74 | + | // - ctx context.Context | |
| 75 | + | // - key string | |
| 76 | + | // - id string | |
| 77 | + | func (_e *BlobStore_Expecter) AbortMultipart(ctx any, key any, id any) *BlobStore_AbortMultipart_Call { | |
| 78 | + | return &BlobStore_AbortMultipart_Call{Call: _e.mock.On("AbortMultipart", ctx, key, id)} | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (_c *BlobStore_AbortMultipart_Call) Run(run func(ctx context.Context, key string, id string)) *BlobStore_AbortMultipart_Call { | |
| 82 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 83 | + | var arg0 context.Context | |
| 84 | + | if args[0] != nil { | |
| 85 | + | arg0 = args[0].(context.Context) | |
| 86 | + | } | |
| 87 | + | var arg1 string | |
| 88 | + | if args[1] != nil { | |
| 89 | + | arg1 = args[1].(string) | |
| 90 | + | } | |
| 91 | + | var arg2 string | |
| 92 | + | if args[2] != nil { | |
| 93 | + | arg2 = args[2].(string) | |
| 94 | + | } | |
| 95 | + | run( | |
| 96 | + | arg0, | |
| 97 | + | arg1, | |
| 98 | + | arg2, | |
| 99 | + | ) | |
| 100 | + | }) | |
| 101 | + | return _c | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func (_c *BlobStore_AbortMultipart_Call) Return(err error) *BlobStore_AbortMultipart_Call { | |
| 105 | + | _c.Call.Return(err) | |
| 106 | + | return _c | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (_c *BlobStore_AbortMultipart_Call) RunAndReturn(run func(ctx context.Context, key string, id string) error) *BlobStore_AbortMultipart_Call { | |
| 110 | + | _c.Call.Return(run) | |
| 111 | + | return _c | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // Check provides a mock function for the type BlobStore | |
| 115 | + | func (_mock *BlobStore) Check(ctx context.Context) error { | |
| 116 | + | ret := _mock.Called(ctx) | |
| 117 | + | ||
| 118 | + | if len(ret) == 0 { | |
| 119 | + | panic("no return value specified for Check") | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | var r0 error | |
| 123 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { | |
| 124 | + | r0 = returnFunc(ctx) | |
| 125 | + | } else { | |
| 126 | + | r0 = ret.Error(0) | |
| 127 | + | } | |
| 128 | + | return r0 | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // BlobStore_Check_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Check' | |
| 132 | + | type BlobStore_Check_Call struct { | |
| 133 | + | *mock.Call | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // Check is a helper method to define mock.On call | |
| 137 | + | // - ctx context.Context | |
| 138 | + | func (_e *BlobStore_Expecter) Check(ctx any) *BlobStore_Check_Call { | |
| 139 | + | return &BlobStore_Check_Call{Call: _e.mock.On("Check", ctx)} | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func (_c *BlobStore_Check_Call) Run(run func(ctx context.Context)) *BlobStore_Check_Call { | |
| 143 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 144 | + | var arg0 context.Context | |
| 145 | + | if args[0] != nil { | |
| 146 | + | arg0 = args[0].(context.Context) | |
| 147 | + | } | |
| 148 | + | run( | |
| 149 | + | arg0, | |
| 150 | + | ) | |
| 151 | + | }) | |
| 152 | + | return _c | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func (_c *BlobStore_Check_Call) Return(err error) *BlobStore_Check_Call { | |
| 156 | + | _c.Call.Return(err) | |
| 157 | + | return _c | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func (_c *BlobStore_Check_Call) RunAndReturn(run func(ctx context.Context) error) *BlobStore_Check_Call { | |
| 161 | + | _c.Call.Return(run) | |
| 162 | + | return _c | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // Delete provides a mock function for the type BlobStore | |
| 166 | + | func (_mock *BlobStore) Delete(ctx context.Context, key string) error { | |
| 167 | + | ret := _mock.Called(ctx, key) | |
| 168 | + | ||
| 169 | + | if len(ret) == 0 { | |
| 170 | + | panic("no return value specified for Delete") | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | var r0 error | |
| 174 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 175 | + | r0 = returnFunc(ctx, key) | |
| 176 | + | } else { | |
| 177 | + | r0 = ret.Error(0) | |
| 178 | + | } | |
| 179 | + | return r0 | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // BlobStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |
| 183 | + | type BlobStore_Delete_Call struct { | |
| 184 | + | *mock.Call | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // Delete is a helper method to define mock.On call | |
| 188 | + | // - ctx context.Context | |
| 189 | + | // - key string | |
| 190 | + | func (_e *BlobStore_Expecter) Delete(ctx any, key any) *BlobStore_Delete_Call { | |
| 191 | + | return &BlobStore_Delete_Call{Call: _e.mock.On("Delete", ctx, key)} | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func (_c *BlobStore_Delete_Call) Run(run func(ctx context.Context, key string)) *BlobStore_Delete_Call { | |
| 195 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 196 | + | var arg0 context.Context | |
| 197 | + | if args[0] != nil { | |
| 198 | + | arg0 = args[0].(context.Context) | |
| 199 | + | } | |
| 200 | + | var arg1 string | |
| 201 | + | if args[1] != nil { | |
| 202 | + | arg1 = args[1].(string) | |
| 203 | + | } | |
| 204 | + | run( | |
| 205 | + | arg0, | |
| 206 | + | arg1, | |
| 207 | + | ) | |
| 208 | + | }) | |
| 209 | + | return _c | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func (_c *BlobStore_Delete_Call) Return(err error) *BlobStore_Delete_Call { | |
| 213 | + | _c.Call.Return(err) | |
| 214 | + | return _c | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func (_c *BlobStore_Delete_Call) RunAndReturn(run func(ctx context.Context, key string) error) *BlobStore_Delete_Call { | |
| 218 | + | _c.Call.Return(run) | |
| 219 | + | return _c | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // List provides a mock function for the type BlobStore | |
| 223 | + | func (_mock *BlobStore) List(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error { | |
| 224 | + | ret := _mock.Called(ctx, prefix, fn) | |
| 225 | + | ||
| 226 | + | if len(ret) == 0 { | |
| 227 | + | panic("no return value specified for List") | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | var r0 error | |
| 231 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, func(ports.BlobInfo) error) error); ok { | |
| 232 | + | r0 = returnFunc(ctx, prefix, fn) | |
| 233 | + | } else { | |
| 234 | + | r0 = ret.Error(0) | |
| 235 | + | } | |
| 236 | + | return r0 | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // BlobStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' | |
| 240 | + | type BlobStore_List_Call struct { | |
| 241 | + | *mock.Call | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // List is a helper method to define mock.On call | |
| 245 | + | // - ctx context.Context | |
| 246 | + | // - prefix string | |
| 247 | + | // - fn func(ports.BlobInfo) error | |
| 248 | + | func (_e *BlobStore_Expecter) List(ctx any, prefix any, fn any) *BlobStore_List_Call { | |
| 249 | + | return &BlobStore_List_Call{Call: _e.mock.On("List", ctx, prefix, fn)} | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func (_c *BlobStore_List_Call) Run(run func(ctx context.Context, prefix string, fn func(ports.BlobInfo) error)) *BlobStore_List_Call { | |
| 253 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 254 | + | var arg0 context.Context | |
| 255 | + | if args[0] != nil { | |
| 256 | + | arg0 = args[0].(context.Context) | |
| 257 | + | } | |
| 258 | + | var arg1 string | |
| 259 | + | if args[1] != nil { | |
| 260 | + | arg1 = args[1].(string) | |
| 261 | + | } | |
| 262 | + | var arg2 func(ports.BlobInfo) error | |
| 263 | + | if args[2] != nil { | |
| 264 | + | arg2 = args[2].(func(ports.BlobInfo) error) | |
| 265 | + | } | |
| 266 | + | run( | |
| 267 | + | arg0, | |
| 268 | + | arg1, | |
| 269 | + | arg2, | |
| 270 | + | ) | |
| 271 | + | }) | |
| 272 | + | return _c | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func (_c *BlobStore_List_Call) Return(err error) *BlobStore_List_Call { | |
| 276 | + | _c.Call.Return(err) | |
| 277 | + | return _c | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | func (_c *BlobStore_List_Call) RunAndReturn(run func(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error) *BlobStore_List_Call { | |
| 281 | + | _c.Call.Return(run) | |
| 282 | + | return _c | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // ListMultipart provides a mock function for the type BlobStore | |
| 286 | + | func (_mock *BlobStore) ListMultipart(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error { | |
| 287 | + | ret := _mock.Called(ctx, prefix, fn) | |
| 288 | + | ||
| 289 | + | if len(ret) == 0 { | |
| 290 | + | panic("no return value specified for ListMultipart") | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | var r0 error | |
| 294 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, func(ports.MultipartInfo) error) error); ok { | |
| 295 | + | r0 = returnFunc(ctx, prefix, fn) | |
| 296 | + | } else { | |
| 297 | + | r0 = ret.Error(0) | |
| 298 | + | } | |
| 299 | + | return r0 | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // BlobStore_ListMultipart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListMultipart' | |
| 303 | + | type BlobStore_ListMultipart_Call struct { | |
| 304 | + | *mock.Call | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | // ListMultipart is a helper method to define mock.On call | |
| 308 | + | // - ctx context.Context | |
| 309 | + | // - prefix string | |
| 310 | + | // - fn func(ports.MultipartInfo) error | |
| 311 | + | func (_e *BlobStore_Expecter) ListMultipart(ctx any, prefix any, fn any) *BlobStore_ListMultipart_Call { | |
| 312 | + | return &BlobStore_ListMultipart_Call{Call: _e.mock.On("ListMultipart", ctx, prefix, fn)} | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | func (_c *BlobStore_ListMultipart_Call) Run(run func(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error)) *BlobStore_ListMultipart_Call { | |
| 316 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 317 | + | var arg0 context.Context | |
| 318 | + | if args[0] != nil { | |
| 319 | + | arg0 = args[0].(context.Context) | |
| 320 | + | } | |
| 321 | + | var arg1 string | |
| 322 | + | if args[1] != nil { | |
| 323 | + | arg1 = args[1].(string) | |
| 324 | + | } | |
| 325 | + | var arg2 func(ports.MultipartInfo) error | |
| 326 | + | if args[2] != nil { | |
| 327 | + | arg2 = args[2].(func(ports.MultipartInfo) error) | |
| 328 | + | } | |
| 329 | + | run( | |
| 330 | + | arg0, | |
| 331 | + | arg1, | |
| 332 | + | arg2, | |
| 333 | + | ) | |
| 334 | + | }) | |
| 335 | + | return _c | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | func (_c *BlobStore_ListMultipart_Call) Return(err error) *BlobStore_ListMultipart_Call { | |
| 339 | + | _c.Call.Return(err) | |
| 340 | + | return _c | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | func (_c *BlobStore_ListMultipart_Call) RunAndReturn(run func(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error) *BlobStore_ListMultipart_Call { | |
| 344 | + | _c.Call.Return(run) | |
| 345 | + | return _c | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | // NewMultipart provides a mock function for the type BlobStore | |
| 349 | + | func (_mock *BlobStore) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) { | |
| 350 | + | ret := _mock.Called(ctx, key) | |
| 351 | + | ||
| 352 | + | if len(ret) == 0 { | |
| 353 | + | panic("no return value specified for NewMultipart") | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | var r0 ports.MultipartUpload | |
| 357 | + | var r1 error | |
| 358 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.MultipartUpload, error)); ok { | |
| 359 | + | return returnFunc(ctx, key) | |
| 360 | + | } | |
| 361 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.MultipartUpload); ok { | |
| 362 | + | r0 = returnFunc(ctx, key) | |
| 363 | + | } else { | |
| 364 | + | if ret.Get(0) != nil { | |
| 365 | + | r0 = ret.Get(0).(ports.MultipartUpload) | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 369 | + | r1 = returnFunc(ctx, key) | |
| 370 | + | } else { | |
| 371 | + | r1 = ret.Error(1) | |
| 372 | + | } | |
| 373 | + | return r0, r1 | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | // BlobStore_NewMultipart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewMultipart' | |
| 377 | + | type BlobStore_NewMultipart_Call struct { | |
| 378 | + | *mock.Call | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | // NewMultipart is a helper method to define mock.On call | |
| 382 | + | // - ctx context.Context | |
| 383 | + | // - key string | |
| 384 | + | func (_e *BlobStore_Expecter) NewMultipart(ctx any, key any) *BlobStore_NewMultipart_Call { | |
| 385 | + | return &BlobStore_NewMultipart_Call{Call: _e.mock.On("NewMultipart", ctx, key)} | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | func (_c *BlobStore_NewMultipart_Call) Run(run func(ctx context.Context, key string)) *BlobStore_NewMultipart_Call { | |
| 389 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 390 | + | var arg0 context.Context | |
| 391 | + | if args[0] != nil { | |
| 392 | + | arg0 = args[0].(context.Context) | |
| 393 | + | } | |
| 394 | + | var arg1 string | |
| 395 | + | if args[1] != nil { | |
| 396 | + | arg1 = args[1].(string) | |
| 397 | + | } | |
| 398 | + | run( | |
| 399 | + | arg0, | |
| 400 | + | arg1, | |
| 401 | + | ) | |
| 402 | + | }) | |
| 403 | + | return _c | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | func (_c *BlobStore_NewMultipart_Call) Return(multipartUpload ports.MultipartUpload, err error) *BlobStore_NewMultipart_Call { | |
| 407 | + | _c.Call.Return(multipartUpload, err) | |
| 408 | + | return _c | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | func (_c *BlobStore_NewMultipart_Call) RunAndReturn(run func(ctx context.Context, key string) (ports.MultipartUpload, error)) *BlobStore_NewMultipart_Call { | |
| 412 | + | _c.Call.Return(run) | |
| 413 | + | return _c | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | // Open provides a mock function for the type BlobStore | |
| 417 | + | func (_mock *BlobStore) Open(ctx context.Context, key string, offset int64, length int64) (io.ReadCloser, ports.BlobInfo, error) { | |
| 418 | + | ret := _mock.Called(ctx, key, offset, length) | |
| 419 | + | ||
| 420 | + | if len(ret) == 0 { | |
| 421 | + | panic("no return value specified for Open") | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | var r0 io.ReadCloser | |
| 425 | + | var r1 ports.BlobInfo | |
| 426 | + | var r2 error | |
| 427 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int64, int64) (io.ReadCloser, ports.BlobInfo, error)); ok { | |
| 428 | + | return returnFunc(ctx, key, offset, length) | |
| 429 | + | } | |
| 430 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int64, int64) io.ReadCloser); ok { | |
| 431 | + | r0 = returnFunc(ctx, key, offset, length) | |
| 432 | + | } else { | |
| 433 | + | if ret.Get(0) != nil { | |
| 434 | + | r0 = ret.Get(0).(io.ReadCloser) | |
| 435 | + | } | |
| 436 | + | } | |
| 437 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, int64, int64) ports.BlobInfo); ok { | |
| 438 | + | r1 = returnFunc(ctx, key, offset, length) | |
| 439 | + | } else { | |
| 440 | + | r1 = ret.Get(1).(ports.BlobInfo) | |
| 441 | + | } | |
| 442 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, string, int64, int64) error); ok { | |
| 443 | + | r2 = returnFunc(ctx, key, offset, length) | |
| 444 | + | } else { | |
| 445 | + | r2 = ret.Error(2) | |
| 446 | + | } | |
| 447 | + | return r0, r1, r2 | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | // BlobStore_Open_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Open' | |
| 451 | + | type BlobStore_Open_Call struct { | |
| 452 | + | *mock.Call | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | // Open is a helper method to define mock.On call | |
| 456 | + | // - ctx context.Context | |
| 457 | + | // - key string | |
| 458 | + | // - offset int64 | |
| 459 | + | // - length int64 | |
| 460 | + | func (_e *BlobStore_Expecter) Open(ctx any, key any, offset any, length any) *BlobStore_Open_Call { | |
| 461 | + | return &BlobStore_Open_Call{Call: _e.mock.On("Open", ctx, key, offset, length)} | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | func (_c *BlobStore_Open_Call) Run(run func(ctx context.Context, key string, offset int64, length int64)) *BlobStore_Open_Call { | |
| 465 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 466 | + | var arg0 context.Context | |
| 467 | + | if args[0] != nil { | |
| 468 | + | arg0 = args[0].(context.Context) | |
| 469 | + | } | |
| 470 | + | var arg1 string | |
| 471 | + | if args[1] != nil { | |
| 472 | + | arg1 = args[1].(string) | |
| 473 | + | } | |
| 474 | + | var arg2 int64 | |
| 475 | + | if args[2] != nil { | |
| 476 | + | arg2 = args[2].(int64) | |
| 477 | + | } | |
| 478 | + | var arg3 int64 | |
| 479 | + | if args[3] != nil { | |
| 480 | + | arg3 = args[3].(int64) | |
| 481 | + | } | |
| 482 | + | run( | |
| 483 | + | arg0, | |
| 484 | + | arg1, | |
| 485 | + | arg2, | |
| 486 | + | arg3, | |
| 487 | + | ) | |
| 488 | + | }) | |
| 489 | + | return _c | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | func (_c *BlobStore_Open_Call) Return(readCloser io.ReadCloser, blobInfo ports.BlobInfo, err error) *BlobStore_Open_Call { | |
| 493 | + | _c.Call.Return(readCloser, blobInfo, err) | |
| 494 | + | return _c | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | func (_c *BlobStore_Open_Call) RunAndReturn(run func(ctx context.Context, key string, offset int64, length int64) (io.ReadCloser, ports.BlobInfo, error)) *BlobStore_Open_Call { | |
| 498 | + | _c.Call.Return(run) | |
| 499 | + | return _c | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | // Put provides a mock function for the type BlobStore | |
| 503 | + | func (_mock *BlobStore) Put(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) { | |
| 504 | + | ret := _mock.Called(ctx, key, r, size) | |
| 505 | + | ||
| 506 | + | if len(ret) == 0 { | |
| 507 | + | panic("no return value specified for Put") | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | var r0 ports.BlobInfo | |
| 511 | + | var r1 error | |
| 512 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, io.Reader, int64) (ports.BlobInfo, error)); ok { | |
| 513 | + | return returnFunc(ctx, key, r, size) | |
| 514 | + | } | |
| 515 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, io.Reader, int64) ports.BlobInfo); ok { | |
| 516 | + | r0 = returnFunc(ctx, key, r, size) | |
| 517 | + | } else { | |
| 518 | + | r0 = ret.Get(0).(ports.BlobInfo) | |
| 519 | + | } | |
| 520 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, io.Reader, int64) error); ok { | |
| 521 | + | r1 = returnFunc(ctx, key, r, size) | |
| 522 | + | } else { | |
| 523 | + | r1 = ret.Error(1) | |
| 524 | + | } | |
| 525 | + | return r0, r1 | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | // BlobStore_Put_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Put' | |
| 529 | + | type BlobStore_Put_Call struct { | |
| 530 | + | *mock.Call | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | // Put is a helper method to define mock.On call | |
| 534 | + | // - ctx context.Context | |
| 535 | + | // - key string | |
| 536 | + | // - r io.Reader | |
| 537 | + | // - size int64 | |
| 538 | + | func (_e *BlobStore_Expecter) Put(ctx any, key any, r any, size any) *BlobStore_Put_Call { | |
| 539 | + | return &BlobStore_Put_Call{Call: _e.mock.On("Put", ctx, key, r, size)} | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | func (_c *BlobStore_Put_Call) Run(run func(ctx context.Context, key string, r io.Reader, size int64)) *BlobStore_Put_Call { | |
| 543 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 544 | + | var arg0 context.Context | |
| 545 | + | if args[0] != nil { | |
| 546 | + | arg0 = args[0].(context.Context) | |
| 547 | + | } | |
| 548 | + | var arg1 string | |
| 549 | + | if args[1] != nil { | |
| 550 | + | arg1 = args[1].(string) | |
| 551 | + | } | |
| 552 | + | var arg2 io.Reader | |
| 553 | + | if args[2] != nil { | |
| 554 | + | arg2 = args[2].(io.Reader) | |
| 555 | + | } | |
| 556 | + | var arg3 int64 | |
| 557 | + | if args[3] != nil { | |
| 558 | + | arg3 = args[3].(int64) | |
| 559 | + | } | |
| 560 | + | run( | |
| 561 | + | arg0, | |
| 562 | + | arg1, | |
| 563 | + | arg2, | |
| 564 | + | arg3, | |
| 565 | + | ) | |
| 566 | + | }) | |
| 567 | + | return _c | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | func (_c *BlobStore_Put_Call) Return(blobInfo ports.BlobInfo, err error) *BlobStore_Put_Call { | |
| 571 | + | _c.Call.Return(blobInfo, err) | |
| 572 | + | return _c | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | func (_c *BlobStore_Put_Call) RunAndReturn(run func(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error)) *BlobStore_Put_Call { | |
| 576 | + | _c.Call.Return(run) | |
| 577 | + | return _c | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | // Stat provides a mock function for the type BlobStore | |
| 581 | + | func (_mock *BlobStore) Stat(ctx context.Context, key string) (ports.BlobInfo, error) { | |
| 582 | + | ret := _mock.Called(ctx, key) | |
| 583 | + | ||
| 584 | + | if len(ret) == 0 { | |
| 585 | + | panic("no return value specified for Stat") | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | var r0 ports.BlobInfo | |
| 589 | + | var r1 error | |
| 590 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.BlobInfo, error)); ok { | |
| 591 | + | return returnFunc(ctx, key) | |
| 592 | + | } | |
| 593 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.BlobInfo); ok { | |
| 594 | + | r0 = returnFunc(ctx, key) | |
| 595 | + | } else { | |
| 596 | + | r0 = ret.Get(0).(ports.BlobInfo) | |
| 597 | + | } | |
| 598 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 599 | + | r1 = returnFunc(ctx, key) | |
| 600 | + | } else { | |
| 601 | + | r1 = ret.Error(1) | |
| 602 | + | } | |
| 603 | + | return r0, r1 | |
| 604 | + | } | |
| 605 | + | ||
| 606 | + | // BlobStore_Stat_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stat' | |
| 607 | + | type BlobStore_Stat_Call struct { | |
| 608 | + | *mock.Call | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | // Stat is a helper method to define mock.On call | |
| 612 | + | // - ctx context.Context | |
| 613 | + | // - key string | |
| 614 | + | func (_e *BlobStore_Expecter) Stat(ctx any, key any) *BlobStore_Stat_Call { | |
| 615 | + | return &BlobStore_Stat_Call{Call: _e.mock.On("Stat", ctx, key)} | |
| 616 | + | } | |
| 617 | + | ||
| 618 | + | func (_c *BlobStore_Stat_Call) Run(run func(ctx context.Context, key string)) *BlobStore_Stat_Call { | |
| 619 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 620 | + | var arg0 context.Context | |
| 621 | + | if args[0] != nil { | |
| 622 | + | arg0 = args[0].(context.Context) | |
| 623 | + | } | |
| 624 | + | var arg1 string | |
| 625 | + | if args[1] != nil { | |
| 626 | + | arg1 = args[1].(string) | |
| 627 | + | } | |
| 628 | + | run( | |
| 629 | + | arg0, | |
| 630 | + | arg1, | |
| 631 | + | ) | |
| 632 | + | }) | |
| 633 | + | return _c | |
| 634 | + | } | |
| 635 | + | ||
| 636 | + | func (_c *BlobStore_Stat_Call) Return(blobInfo ports.BlobInfo, err error) *BlobStore_Stat_Call { | |
| 637 | + | _c.Call.Return(blobInfo, err) | |
| 638 | + | return _c | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | func (_c *BlobStore_Stat_Call) RunAndReturn(run func(ctx context.Context, key string) (ports.BlobInfo, error)) *BlobStore_Stat_Call { | |
| 642 | + | _c.Call.Return(run) | |
| 643 | + | return _c | |
| 644 | + | } | |
| @@ -0,0 +1,644 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | "io" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/ermite/internal/ports" | |||
| 12 | + | mock "github.com/stretchr/testify/mock" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // NewBlobStore creates a new instance of BlobStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 16 | + | // The first argument is typically a *testing.T value. | |||
| 17 | + | func NewBlobStore(t interface { | |||
| 18 | + | mock.TestingT | |||
| 19 | + | Cleanup(func()) | |||
| 20 | + | }) *BlobStore { | |||
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 22 | + | helper.Helper() | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | mock := &BlobStore{} | |||
| 26 | + | mock.Mock.Test(t) | |||
| 27 | + | ||||
| 28 | + | t.Cleanup(func() { | |||
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 30 | + | helper.Helper() | |||
| 31 | + | } | |||
| 32 | + | mock.AssertExpectations(t) | |||
| 33 | + | }) | |||
| 34 | + | ||||
| 35 | + | return mock | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // BlobStore is an autogenerated mock type for the BlobStore type | |||
| 39 | + | type BlobStore struct { | |||
| 40 | + | mock.Mock | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type BlobStore_Expecter struct { | |||
| 44 | + | mock *mock.Mock | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (_m *BlobStore) EXPECT() *BlobStore_Expecter { | |||
| 48 | + | return &BlobStore_Expecter{mock: &_m.Mock} | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // AbortMultipart provides a mock function for the type BlobStore | |||
| 52 | + | func (_mock *BlobStore) AbortMultipart(ctx context.Context, key string, id string) error { | |||
| 53 | + | ret := _mock.Called(ctx, key, id) | |||
| 54 | + | ||||
| 55 | + | if len(ret) == 0 { | |||
| 56 | + | panic("no return value specified for AbortMultipart") | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | var r0 error | |||
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { | |||
| 61 | + | r0 = returnFunc(ctx, key, id) | |||
| 62 | + | } else { | |||
| 63 | + | r0 = ret.Error(0) | |||
| 64 | + | } | |||
| 65 | + | return r0 | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // BlobStore_AbortMultipart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AbortMultipart' | |||
| 69 | + | type BlobStore_AbortMultipart_Call struct { | |||
| 70 | + | *mock.Call | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // AbortMultipart is a helper method to define mock.On call | |||
| 74 | + | // - ctx context.Context | |||
| 75 | + | // - key string | |||
| 76 | + | // - id string | |||
| 77 | + | func (_e *BlobStore_Expecter) AbortMultipart(ctx any, key any, id any) *BlobStore_AbortMultipart_Call { | |||
| 78 | + | return &BlobStore_AbortMultipart_Call{Call: _e.mock.On("AbortMultipart", ctx, key, id)} | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func (_c *BlobStore_AbortMultipart_Call) Run(run func(ctx context.Context, key string, id string)) *BlobStore_AbortMultipart_Call { | |||
| 82 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 83 | + | var arg0 context.Context | |||
| 84 | + | if args[0] != nil { | |||
| 85 | + | arg0 = args[0].(context.Context) | |||
| 86 | + | } | |||
| 87 | + | var arg1 string | |||
| 88 | + | if args[1] != nil { | |||
| 89 | + | arg1 = args[1].(string) | |||
| 90 | + | } | |||
| 91 | + | var arg2 string | |||
| 92 | + | if args[2] != nil { | |||
| 93 | + | arg2 = args[2].(string) | |||
| 94 | + | } | |||
| 95 | + | run( | |||
| 96 | + | arg0, | |||
| 97 | + | arg1, | |||
| 98 | + | arg2, | |||
| 99 | + | ) | |||
| 100 | + | }) | |||
| 101 | + | return _c | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func (_c *BlobStore_AbortMultipart_Call) Return(err error) *BlobStore_AbortMultipart_Call { | |||
| 105 | + | _c.Call.Return(err) | |||
| 106 | + | return _c | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func (_c *BlobStore_AbortMultipart_Call) RunAndReturn(run func(ctx context.Context, key string, id string) error) *BlobStore_AbortMultipart_Call { | |||
| 110 | + | _c.Call.Return(run) | |||
| 111 | + | return _c | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // Check provides a mock function for the type BlobStore | |||
| 115 | + | func (_mock *BlobStore) Check(ctx context.Context) error { | |||
| 116 | + | ret := _mock.Called(ctx) | |||
| 117 | + | ||||
| 118 | + | if len(ret) == 0 { | |||
| 119 | + | panic("no return value specified for Check") | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | var r0 error | |||
| 123 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { | |||
| 124 | + | r0 = returnFunc(ctx) | |||
| 125 | + | } else { | |||
| 126 | + | r0 = ret.Error(0) | |||
| 127 | + | } | |||
| 128 | + | return r0 | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // BlobStore_Check_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Check' | |||
| 132 | + | type BlobStore_Check_Call struct { | |||
| 133 | + | *mock.Call | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // Check is a helper method to define mock.On call | |||
| 137 | + | // - ctx context.Context | |||
| 138 | + | func (_e *BlobStore_Expecter) Check(ctx any) *BlobStore_Check_Call { | |||
| 139 | + | return &BlobStore_Check_Call{Call: _e.mock.On("Check", ctx)} | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func (_c *BlobStore_Check_Call) Run(run func(ctx context.Context)) *BlobStore_Check_Call { | |||
| 143 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 144 | + | var arg0 context.Context | |||
| 145 | + | if args[0] != nil { | |||
| 146 | + | arg0 = args[0].(context.Context) | |||
| 147 | + | } | |||
| 148 | + | run( | |||
| 149 | + | arg0, | |||
| 150 | + | ) | |||
| 151 | + | }) | |||
| 152 | + | return _c | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func (_c *BlobStore_Check_Call) Return(err error) *BlobStore_Check_Call { | |||
| 156 | + | _c.Call.Return(err) | |||
| 157 | + | return _c | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func (_c *BlobStore_Check_Call) RunAndReturn(run func(ctx context.Context) error) *BlobStore_Check_Call { | |||
| 161 | + | _c.Call.Return(run) | |||
| 162 | + | return _c | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // Delete provides a mock function for the type BlobStore | |||
| 166 | + | func (_mock *BlobStore) Delete(ctx context.Context, key string) error { | |||
| 167 | + | ret := _mock.Called(ctx, key) | |||
| 168 | + | ||||
| 169 | + | if len(ret) == 0 { | |||
| 170 | + | panic("no return value specified for Delete") | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | var r0 error | |||
| 174 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |||
| 175 | + | r0 = returnFunc(ctx, key) | |||
| 176 | + | } else { | |||
| 177 | + | r0 = ret.Error(0) | |||
| 178 | + | } | |||
| 179 | + | return r0 | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // BlobStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |||
| 183 | + | type BlobStore_Delete_Call struct { | |||
| 184 | + | *mock.Call | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | // Delete is a helper method to define mock.On call | |||
| 188 | + | // - ctx context.Context | |||
| 189 | + | // - key string | |||
| 190 | + | func (_e *BlobStore_Expecter) Delete(ctx any, key any) *BlobStore_Delete_Call { | |||
| 191 | + | return &BlobStore_Delete_Call{Call: _e.mock.On("Delete", ctx, key)} | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func (_c *BlobStore_Delete_Call) Run(run func(ctx context.Context, key string)) *BlobStore_Delete_Call { | |||
| 195 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 196 | + | var arg0 context.Context | |||
| 197 | + | if args[0] != nil { | |||
| 198 | + | arg0 = args[0].(context.Context) | |||
| 199 | + | } | |||
| 200 | + | var arg1 string | |||
| 201 | + | if args[1] != nil { | |||
| 202 | + | arg1 = args[1].(string) | |||
| 203 | + | } | |||
| 204 | + | run( | |||
| 205 | + | arg0, | |||
| 206 | + | arg1, | |||
| 207 | + | ) | |||
| 208 | + | }) | |||
| 209 | + | return _c | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func (_c *BlobStore_Delete_Call) Return(err error) *BlobStore_Delete_Call { | |||
| 213 | + | _c.Call.Return(err) | |||
| 214 | + | return _c | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func (_c *BlobStore_Delete_Call) RunAndReturn(run func(ctx context.Context, key string) error) *BlobStore_Delete_Call { | |||
| 218 | + | _c.Call.Return(run) | |||
| 219 | + | return _c | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | // List provides a mock function for the type BlobStore | |||
| 223 | + | func (_mock *BlobStore) List(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error { | |||
| 224 | + | ret := _mock.Called(ctx, prefix, fn) | |||
| 225 | + | ||||
| 226 | + | if len(ret) == 0 { | |||
| 227 | + | panic("no return value specified for List") | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | var r0 error | |||
| 231 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, func(ports.BlobInfo) error) error); ok { | |||
| 232 | + | r0 = returnFunc(ctx, prefix, fn) | |||
| 233 | + | } else { | |||
| 234 | + | r0 = ret.Error(0) | |||
| 235 | + | } | |||
| 236 | + | return r0 | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // BlobStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' | |||
| 240 | + | type BlobStore_List_Call struct { | |||
| 241 | + | *mock.Call | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | // List is a helper method to define mock.On call | |||
| 245 | + | // - ctx context.Context | |||
| 246 | + | // - prefix string | |||
| 247 | + | // - fn func(ports.BlobInfo) error | |||
| 248 | + | func (_e *BlobStore_Expecter) List(ctx any, prefix any, fn any) *BlobStore_List_Call { | |||
| 249 | + | return &BlobStore_List_Call{Call: _e.mock.On("List", ctx, prefix, fn)} | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func (_c *BlobStore_List_Call) Run(run func(ctx context.Context, prefix string, fn func(ports.BlobInfo) error)) *BlobStore_List_Call { | |||
| 253 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 254 | + | var arg0 context.Context | |||
| 255 | + | if args[0] != nil { | |||
| 256 | + | arg0 = args[0].(context.Context) | |||
| 257 | + | } | |||
| 258 | + | var arg1 string | |||
| 259 | + | if args[1] != nil { | |||
| 260 | + | arg1 = args[1].(string) | |||
| 261 | + | } | |||
| 262 | + | var arg2 func(ports.BlobInfo) error | |||
| 263 | + | if args[2] != nil { | |||
| 264 | + | arg2 = args[2].(func(ports.BlobInfo) error) | |||
| 265 | + | } | |||
| 266 | + | run( | |||
| 267 | + | arg0, | |||
| 268 | + | arg1, | |||
| 269 | + | arg2, | |||
| 270 | + | ) | |||
| 271 | + | }) | |||
| 272 | + | return _c | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | func (_c *BlobStore_List_Call) Return(err error) *BlobStore_List_Call { | |||
| 276 | + | _c.Call.Return(err) | |||
| 277 | + | return _c | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | func (_c *BlobStore_List_Call) RunAndReturn(run func(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error) *BlobStore_List_Call { | |||
| 281 | + | _c.Call.Return(run) | |||
| 282 | + | return _c | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | // ListMultipart provides a mock function for the type BlobStore | |||
| 286 | + | func (_mock *BlobStore) ListMultipart(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error { | |||
| 287 | + | ret := _mock.Called(ctx, prefix, fn) | |||
| 288 | + | ||||
| 289 | + | if len(ret) == 0 { | |||
| 290 | + | panic("no return value specified for ListMultipart") | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | var r0 error | |||
| 294 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, func(ports.MultipartInfo) error) error); ok { | |||
| 295 | + | r0 = returnFunc(ctx, prefix, fn) | |||
| 296 | + | } else { | |||
| 297 | + | r0 = ret.Error(0) | |||
| 298 | + | } | |||
| 299 | + | return r0 | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | // BlobStore_ListMultipart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListMultipart' | |||
| 303 | + | type BlobStore_ListMultipart_Call struct { | |||
| 304 | + | *mock.Call | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | // ListMultipart is a helper method to define mock.On call | |||
| 308 | + | // - ctx context.Context | |||
| 309 | + | // - prefix string | |||
| 310 | + | // - fn func(ports.MultipartInfo) error | |||
| 311 | + | func (_e *BlobStore_Expecter) ListMultipart(ctx any, prefix any, fn any) *BlobStore_ListMultipart_Call { | |||
| 312 | + | return &BlobStore_ListMultipart_Call{Call: _e.mock.On("ListMultipart", ctx, prefix, fn)} | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | func (_c *BlobStore_ListMultipart_Call) Run(run func(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error)) *BlobStore_ListMultipart_Call { | |||
| 316 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 317 | + | var arg0 context.Context | |||
| 318 | + | if args[0] != nil { | |||
| 319 | + | arg0 = args[0].(context.Context) | |||
| 320 | + | } | |||
| 321 | + | var arg1 string | |||
| 322 | + | if args[1] != nil { | |||
| 323 | + | arg1 = args[1].(string) | |||
| 324 | + | } | |||
| 325 | + | var arg2 func(ports.MultipartInfo) error | |||
| 326 | + | if args[2] != nil { | |||
| 327 | + | arg2 = args[2].(func(ports.MultipartInfo) error) | |||
| 328 | + | } | |||
| 329 | + | run( | |||
| 330 | + | arg0, | |||
| 331 | + | arg1, | |||
| 332 | + | arg2, | |||
| 333 | + | ) | |||
| 334 | + | }) | |||
| 335 | + | return _c | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | func (_c *BlobStore_ListMultipart_Call) Return(err error) *BlobStore_ListMultipart_Call { | |||
| 339 | + | _c.Call.Return(err) | |||
| 340 | + | return _c | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | func (_c *BlobStore_ListMultipart_Call) RunAndReturn(run func(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error) *BlobStore_ListMultipart_Call { | |||
| 344 | + | _c.Call.Return(run) | |||
| 345 | + | return _c | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | // NewMultipart provides a mock function for the type BlobStore | |||
| 349 | + | func (_mock *BlobStore) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) { | |||
| 350 | + | ret := _mock.Called(ctx, key) | |||
| 351 | + | ||||
| 352 | + | if len(ret) == 0 { | |||
| 353 | + | panic("no return value specified for NewMultipart") | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | var r0 ports.MultipartUpload | |||
| 357 | + | var r1 error | |||
| 358 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.MultipartUpload, error)); ok { | |||
| 359 | + | return returnFunc(ctx, key) | |||
| 360 | + | } | |||
| 361 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.MultipartUpload); ok { | |||
| 362 | + | r0 = returnFunc(ctx, key) | |||
| 363 | + | } else { | |||
| 364 | + | if ret.Get(0) != nil { | |||
| 365 | + | r0 = ret.Get(0).(ports.MultipartUpload) | |||
| 366 | + | } | |||
| 367 | + | } | |||
| 368 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |||
| 369 | + | r1 = returnFunc(ctx, key) | |||
| 370 | + | } else { | |||
| 371 | + | r1 = ret.Error(1) | |||
| 372 | + | } | |||
| 373 | + | return r0, r1 | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | // BlobStore_NewMultipart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewMultipart' | |||
| 377 | + | type BlobStore_NewMultipart_Call struct { | |||
| 378 | + | *mock.Call | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | // NewMultipart is a helper method to define mock.On call | |||
| 382 | + | // - ctx context.Context | |||
| 383 | + | // - key string | |||
| 384 | + | func (_e *BlobStore_Expecter) NewMultipart(ctx any, key any) *BlobStore_NewMultipart_Call { | |||
| 385 | + | return &BlobStore_NewMultipart_Call{Call: _e.mock.On("NewMultipart", ctx, key)} | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | func (_c *BlobStore_NewMultipart_Call) Run(run func(ctx context.Context, key string)) *BlobStore_NewMultipart_Call { | |||
| 389 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 390 | + | var arg0 context.Context | |||
| 391 | + | if args[0] != nil { | |||
| 392 | + | arg0 = args[0].(context.Context) | |||
| 393 | + | } | |||
| 394 | + | var arg1 string | |||
| 395 | + | if args[1] != nil { | |||
| 396 | + | arg1 = args[1].(string) | |||
| 397 | + | } | |||
| 398 | + | run( | |||
| 399 | + | arg0, | |||
| 400 | + | arg1, | |||
| 401 | + | ) | |||
| 402 | + | }) | |||
| 403 | + | return _c | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | func (_c *BlobStore_NewMultipart_Call) Return(multipartUpload ports.MultipartUpload, err error) *BlobStore_NewMultipart_Call { | |||
| 407 | + | _c.Call.Return(multipartUpload, err) | |||
| 408 | + | return _c | |||
| 409 | + | } | |||
| 410 | + | ||||
| 411 | + | func (_c *BlobStore_NewMultipart_Call) RunAndReturn(run func(ctx context.Context, key string) (ports.MultipartUpload, error)) *BlobStore_NewMultipart_Call { | |||
| 412 | + | _c.Call.Return(run) | |||
| 413 | + | return _c | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | // Open provides a mock function for the type BlobStore | |||
| 417 | + | func (_mock *BlobStore) Open(ctx context.Context, key string, offset int64, length int64) (io.ReadCloser, ports.BlobInfo, error) { | |||
| 418 | + | ret := _mock.Called(ctx, key, offset, length) | |||
| 419 | + | ||||
| 420 | + | if len(ret) == 0 { | |||
| 421 | + | panic("no return value specified for Open") | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | var r0 io.ReadCloser | |||
| 425 | + | var r1 ports.BlobInfo | |||
| 426 | + | var r2 error | |||
| 427 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int64, int64) (io.ReadCloser, ports.BlobInfo, error)); ok { | |||
| 428 | + | return returnFunc(ctx, key, offset, length) | |||
| 429 | + | } | |||
| 430 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int64, int64) io.ReadCloser); ok { | |||
| 431 | + | r0 = returnFunc(ctx, key, offset, length) | |||
| 432 | + | } else { | |||
| 433 | + | if ret.Get(0) != nil { | |||
| 434 | + | r0 = ret.Get(0).(io.ReadCloser) | |||
| 435 | + | } | |||
| 436 | + | } | |||
| 437 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, int64, int64) ports.BlobInfo); ok { | |||
| 438 | + | r1 = returnFunc(ctx, key, offset, length) | |||
| 439 | + | } else { | |||
| 440 | + | r1 = ret.Get(1).(ports.BlobInfo) | |||
| 441 | + | } | |||
| 442 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, string, int64, int64) error); ok { | |||
| 443 | + | r2 = returnFunc(ctx, key, offset, length) | |||
| 444 | + | } else { | |||
| 445 | + | r2 = ret.Error(2) | |||
| 446 | + | } | |||
| 447 | + | return r0, r1, r2 | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | // BlobStore_Open_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Open' | |||
| 451 | + | type BlobStore_Open_Call struct { | |||
| 452 | + | *mock.Call | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | // Open is a helper method to define mock.On call | |||
| 456 | + | // - ctx context.Context | |||
| 457 | + | // - key string | |||
| 458 | + | // - offset int64 | |||
| 459 | + | // - length int64 | |||
| 460 | + | func (_e *BlobStore_Expecter) Open(ctx any, key any, offset any, length any) *BlobStore_Open_Call { | |||
| 461 | + | return &BlobStore_Open_Call{Call: _e.mock.On("Open", ctx, key, offset, length)} | |||
| 462 | + | } | |||
| 463 | + | ||||
| 464 | + | func (_c *BlobStore_Open_Call) Run(run func(ctx context.Context, key string, offset int64, length int64)) *BlobStore_Open_Call { | |||
| 465 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 466 | + | var arg0 context.Context | |||
| 467 | + | if args[0] != nil { | |||
| 468 | + | arg0 = args[0].(context.Context) | |||
| 469 | + | } | |||
| 470 | + | var arg1 string | |||
| 471 | + | if args[1] != nil { | |||
| 472 | + | arg1 = args[1].(string) | |||
| 473 | + | } | |||
| 474 | + | var arg2 int64 | |||
| 475 | + | if args[2] != nil { | |||
| 476 | + | arg2 = args[2].(int64) | |||
| 477 | + | } | |||
| 478 | + | var arg3 int64 | |||
| 479 | + | if args[3] != nil { | |||
| 480 | + | arg3 = args[3].(int64) | |||
| 481 | + | } | |||
| 482 | + | run( | |||
| 483 | + | arg0, | |||
| 484 | + | arg1, | |||
| 485 | + | arg2, | |||
| 486 | + | arg3, | |||
| 487 | + | ) | |||
| 488 | + | }) | |||
| 489 | + | return _c | |||
| 490 | + | } | |||
| 491 | + | ||||
| 492 | + | func (_c *BlobStore_Open_Call) Return(readCloser io.ReadCloser, blobInfo ports.BlobInfo, err error) *BlobStore_Open_Call { | |||
| 493 | + | _c.Call.Return(readCloser, blobInfo, err) | |||
| 494 | + | return _c | |||
| 495 | + | } | |||
| 496 | + | ||||
| 497 | + | func (_c *BlobStore_Open_Call) RunAndReturn(run func(ctx context.Context, key string, offset int64, length int64) (io.ReadCloser, ports.BlobInfo, error)) *BlobStore_Open_Call { | |||
| 498 | + | _c.Call.Return(run) | |||
| 499 | + | return _c | |||
| 500 | + | } | |||
| 501 | + | ||||
| 502 | + | // Put provides a mock function for the type BlobStore | |||
| 503 | + | func (_mock *BlobStore) Put(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) { | |||
| 504 | + | ret := _mock.Called(ctx, key, r, size) | |||
| 505 | + | ||||
| 506 | + | if len(ret) == 0 { | |||
| 507 | + | panic("no return value specified for Put") | |||
| 508 | + | } | |||
| 509 | + | ||||
| 510 | + | var r0 ports.BlobInfo | |||
| 511 | + | var r1 error | |||
| 512 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, io.Reader, int64) (ports.BlobInfo, error)); ok { | |||
| 513 | + | return returnFunc(ctx, key, r, size) | |||
| 514 | + | } | |||
| 515 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, io.Reader, int64) ports.BlobInfo); ok { | |||
| 516 | + | r0 = returnFunc(ctx, key, r, size) | |||
| 517 | + | } else { | |||
| 518 | + | r0 = ret.Get(0).(ports.BlobInfo) | |||
| 519 | + | } | |||
| 520 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, io.Reader, int64) error); ok { | |||
| 521 | + | r1 = returnFunc(ctx, key, r, size) | |||
| 522 | + | } else { | |||
| 523 | + | r1 = ret.Error(1) | |||
| 524 | + | } | |||
| 525 | + | return r0, r1 | |||
| 526 | + | } | |||
| 527 | + | ||||
| 528 | + | // BlobStore_Put_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Put' | |||
| 529 | + | type BlobStore_Put_Call struct { | |||
| 530 | + | *mock.Call | |||
| 531 | + | } | |||
| 532 | + | ||||
| 533 | + | // Put is a helper method to define mock.On call | |||
| 534 | + | // - ctx context.Context | |||
| 535 | + | // - key string | |||
| 536 | + | // - r io.Reader | |||
| 537 | + | // - size int64 | |||
| 538 | + | func (_e *BlobStore_Expecter) Put(ctx any, key any, r any, size any) *BlobStore_Put_Call { | |||
| 539 | + | return &BlobStore_Put_Call{Call: _e.mock.On("Put", ctx, key, r, size)} | |||
| 540 | + | } | |||
| 541 | + | ||||
| 542 | + | func (_c *BlobStore_Put_Call) Run(run func(ctx context.Context, key string, r io.Reader, size int64)) *BlobStore_Put_Call { | |||
| 543 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 544 | + | var arg0 context.Context | |||
| 545 | + | if args[0] != nil { | |||
| 546 | + | arg0 = args[0].(context.Context) | |||
| 547 | + | } | |||
| 548 | + | var arg1 string | |||
| 549 | + | if args[1] != nil { | |||
| 550 | + | arg1 = args[1].(string) | |||
| 551 | + | } | |||
| 552 | + | var arg2 io.Reader | |||
| 553 | + | if args[2] != nil { | |||
| 554 | + | arg2 = args[2].(io.Reader) | |||
| 555 | + | } | |||
| 556 | + | var arg3 int64 | |||
| 557 | + | if args[3] != nil { | |||
| 558 | + | arg3 = args[3].(int64) | |||
| 559 | + | } | |||
| 560 | + | run( | |||
| 561 | + | arg0, | |||
| 562 | + | arg1, | |||
| 563 | + | arg2, | |||
| 564 | + | arg3, | |||
| 565 | + | ) | |||
| 566 | + | }) | |||
| 567 | + | return _c | |||
| 568 | + | } | |||
| 569 | + | ||||
| 570 | + | func (_c *BlobStore_Put_Call) Return(blobInfo ports.BlobInfo, err error) *BlobStore_Put_Call { | |||
| 571 | + | _c.Call.Return(blobInfo, err) | |||
| 572 | + | return _c | |||
| 573 | + | } | |||
| 574 | + | ||||
| 575 | + | func (_c *BlobStore_Put_Call) RunAndReturn(run func(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error)) *BlobStore_Put_Call { | |||
| 576 | + | _c.Call.Return(run) | |||
| 577 | + | return _c | |||
| 578 | + | } | |||
| 579 | + | ||||
| 580 | + | // Stat provides a mock function for the type BlobStore | |||
| 581 | + | func (_mock *BlobStore) Stat(ctx context.Context, key string) (ports.BlobInfo, error) { | |||
| 582 | + | ret := _mock.Called(ctx, key) | |||
| 583 | + | ||||
| 584 | + | if len(ret) == 0 { | |||
| 585 | + | panic("no return value specified for Stat") | |||
| 586 | + | } | |||
| 587 | + | ||||
| 588 | + | var r0 ports.BlobInfo | |||
| 589 | + | var r1 error | |||
| 590 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.BlobInfo, error)); ok { | |||
| 591 | + | return returnFunc(ctx, key) | |||
| 592 | + | } | |||
| 593 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.BlobInfo); ok { | |||
| 594 | + | r0 = returnFunc(ctx, key) | |||
| 595 | + | } else { | |||
| 596 | + | r0 = ret.Get(0).(ports.BlobInfo) | |||
| 597 | + | } | |||
| 598 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |||
| 599 | + | r1 = returnFunc(ctx, key) | |||
| 600 | + | } else { | |||
| 601 | + | r1 = ret.Error(1) | |||
| 602 | + | } | |||
| 603 | + | return r0, r1 | |||
| 604 | + | } | |||
| 605 | + | ||||
| 606 | + | // BlobStore_Stat_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stat' | |||
| 607 | + | type BlobStore_Stat_Call struct { | |||
| 608 | + | *mock.Call | |||
| 609 | + | } | |||
| 610 | + | ||||
| 611 | + | // Stat is a helper method to define mock.On call | |||
| 612 | + | // - ctx context.Context | |||
| 613 | + | // - key string | |||
| 614 | + | func (_e *BlobStore_Expecter) Stat(ctx any, key any) *BlobStore_Stat_Call { | |||
| 615 | + | return &BlobStore_Stat_Call{Call: _e.mock.On("Stat", ctx, key)} | |||
| 616 | + | } | |||
| 617 | + | ||||
| 618 | + | func (_c *BlobStore_Stat_Call) Run(run func(ctx context.Context, key string)) *BlobStore_Stat_Call { | |||
| 619 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 620 | + | var arg0 context.Context | |||
| 621 | + | if args[0] != nil { | |||
| 622 | + | arg0 = args[0].(context.Context) | |||
| 623 | + | } | |||
| 624 | + | var arg1 string | |||
| 625 | + | if args[1] != nil { | |||
| 626 | + | arg1 = args[1].(string) | |||
| 627 | + | } | |||
| 628 | + | run( | |||
| 629 | + | arg0, | |||
| 630 | + | arg1, | |||
| 631 | + | ) | |||
| 632 | + | }) | |||
| 633 | + | return _c | |||
| 634 | + | } | |||
| 635 | + | ||||
| 636 | + | func (_c *BlobStore_Stat_Call) Return(blobInfo ports.BlobInfo, err error) *BlobStore_Stat_Call { | |||
| 637 | + | _c.Call.Return(blobInfo, err) | |||
| 638 | + | return _c | |||
| 639 | + | } | |||
| 640 | + | ||||
| 641 | + | func (_c *BlobStore_Stat_Call) RunAndReturn(run func(ctx context.Context, key string) (ports.BlobInfo, error)) *BlobStore_Stat_Call { | |||
| 642 | + | _c.Call.Return(run) | |||
| 643 | + | return _c | |||
| 644 | + | } | |||
A internal/ports/mocks/MultipartUpload.go +273 -0
| @@ -0,0 +1,273 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "io" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewMultipartUpload creates a new instance of MultipartUpload. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | + | // The first argument is typically a *testing.T value. | |
| 17 | + | func NewMultipartUpload(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *MultipartUpload { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &MultipartUpload{} | |
| 26 | + | mock.Mock.Test(t) | |
| 27 | + | ||
| 28 | + | t.Cleanup(func() { | |
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 30 | + | helper.Helper() | |
| 31 | + | } | |
| 32 | + | mock.AssertExpectations(t) | |
| 33 | + | }) | |
| 34 | + | ||
| 35 | + | return mock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // MultipartUpload is an autogenerated mock type for the MultipartUpload type | |
| 39 | + | type MultipartUpload struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type MultipartUpload_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *MultipartUpload) EXPECT() *MultipartUpload_Expecter { | |
| 48 | + | return &MultipartUpload_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Abort provides a mock function for the type MultipartUpload | |
| 52 | + | func (_mock *MultipartUpload) Abort(ctx context.Context) error { | |
| 53 | + | ret := _mock.Called(ctx) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for Abort") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { | |
| 61 | + | r0 = returnFunc(ctx) | |
| 62 | + | } else { | |
| 63 | + | r0 = ret.Error(0) | |
| 64 | + | } | |
| 65 | + | return r0 | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // MultipartUpload_Abort_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Abort' | |
| 69 | + | type MultipartUpload_Abort_Call struct { | |
| 70 | + | *mock.Call | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Abort is a helper method to define mock.On call | |
| 74 | + | // - ctx context.Context | |
| 75 | + | func (_e *MultipartUpload_Expecter) Abort(ctx any) *MultipartUpload_Abort_Call { | |
| 76 | + | return &MultipartUpload_Abort_Call{Call: _e.mock.On("Abort", ctx)} | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (_c *MultipartUpload_Abort_Call) Run(run func(ctx context.Context)) *MultipartUpload_Abort_Call { | |
| 80 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 81 | + | var arg0 context.Context | |
| 82 | + | if args[0] != nil { | |
| 83 | + | arg0 = args[0].(context.Context) | |
| 84 | + | } | |
| 85 | + | run( | |
| 86 | + | arg0, | |
| 87 | + | ) | |
| 88 | + | }) | |
| 89 | + | return _c | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (_c *MultipartUpload_Abort_Call) Return(err error) *MultipartUpload_Abort_Call { | |
| 93 | + | _c.Call.Return(err) | |
| 94 | + | return _c | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (_c *MultipartUpload_Abort_Call) RunAndReturn(run func(ctx context.Context) error) *MultipartUpload_Abort_Call { | |
| 98 | + | _c.Call.Return(run) | |
| 99 | + | return _c | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // Complete provides a mock function for the type MultipartUpload | |
| 103 | + | func (_mock *MultipartUpload) Complete(ctx context.Context) (ports.BlobInfo, error) { | |
| 104 | + | ret := _mock.Called(ctx) | |
| 105 | + | ||
| 106 | + | if len(ret) == 0 { | |
| 107 | + | panic("no return value specified for Complete") | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | var r0 ports.BlobInfo | |
| 111 | + | var r1 error | |
| 112 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (ports.BlobInfo, error)); ok { | |
| 113 | + | return returnFunc(ctx) | |
| 114 | + | } | |
| 115 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ports.BlobInfo); ok { | |
| 116 | + | r0 = returnFunc(ctx) | |
| 117 | + | } else { | |
| 118 | + | r0 = ret.Get(0).(ports.BlobInfo) | |
| 119 | + | } | |
| 120 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 121 | + | r1 = returnFunc(ctx) | |
| 122 | + | } else { | |
| 123 | + | r1 = ret.Error(1) | |
| 124 | + | } | |
| 125 | + | return r0, r1 | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // MultipartUpload_Complete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Complete' | |
| 129 | + | type MultipartUpload_Complete_Call struct { | |
| 130 | + | *mock.Call | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // Complete is a helper method to define mock.On call | |
| 134 | + | // - ctx context.Context | |
| 135 | + | func (_e *MultipartUpload_Expecter) Complete(ctx any) *MultipartUpload_Complete_Call { | |
| 136 | + | return &MultipartUpload_Complete_Call{Call: _e.mock.On("Complete", ctx)} | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (_c *MultipartUpload_Complete_Call) Run(run func(ctx context.Context)) *MultipartUpload_Complete_Call { | |
| 140 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 141 | + | var arg0 context.Context | |
| 142 | + | if args[0] != nil { | |
| 143 | + | arg0 = args[0].(context.Context) | |
| 144 | + | } | |
| 145 | + | run( | |
| 146 | + | arg0, | |
| 147 | + | ) | |
| 148 | + | }) | |
| 149 | + | return _c | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func (_c *MultipartUpload_Complete_Call) Return(blobInfo ports.BlobInfo, err error) *MultipartUpload_Complete_Call { | |
| 153 | + | _c.Call.Return(blobInfo, err) | |
| 154 | + | return _c | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func (_c *MultipartUpload_Complete_Call) RunAndReturn(run func(ctx context.Context) (ports.BlobInfo, error)) *MultipartUpload_Complete_Call { | |
| 158 | + | _c.Call.Return(run) | |
| 159 | + | return _c | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // ID provides a mock function for the type MultipartUpload | |
| 163 | + | func (_mock *MultipartUpload) ID() string { | |
| 164 | + | ret := _mock.Called() | |
| 165 | + | ||
| 166 | + | if len(ret) == 0 { | |
| 167 | + | panic("no return value specified for ID") | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | var r0 string | |
| 171 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |
| 172 | + | r0 = returnFunc() | |
| 173 | + | } else { | |
| 174 | + | r0 = ret.Get(0).(string) | |
| 175 | + | } | |
| 176 | + | return r0 | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // MultipartUpload_ID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ID' | |
| 180 | + | type MultipartUpload_ID_Call struct { | |
| 181 | + | *mock.Call | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // ID is a helper method to define mock.On call | |
| 185 | + | func (_e *MultipartUpload_Expecter) ID() *MultipartUpload_ID_Call { | |
| 186 | + | return &MultipartUpload_ID_Call{Call: _e.mock.On("ID")} | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func (_c *MultipartUpload_ID_Call) Run(run func()) *MultipartUpload_ID_Call { | |
| 190 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 191 | + | run() | |
| 192 | + | }) | |
| 193 | + | return _c | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func (_c *MultipartUpload_ID_Call) Return(s string) *MultipartUpload_ID_Call { | |
| 197 | + | _c.Call.Return(s) | |
| 198 | + | return _c | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func (_c *MultipartUpload_ID_Call) RunAndReturn(run func() string) *MultipartUpload_ID_Call { | |
| 202 | + | _c.Call.Return(run) | |
| 203 | + | return _c | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // UploadPart provides a mock function for the type MultipartUpload | |
| 207 | + | func (_mock *MultipartUpload) UploadPart(ctx context.Context, n int, r io.Reader, size int64) error { | |
| 208 | + | ret := _mock.Called(ctx, n, r, size) | |
| 209 | + | ||
| 210 | + | if len(ret) == 0 { | |
| 211 | + | panic("no return value specified for UploadPart") | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | var r0 error | |
| 215 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int, io.Reader, int64) error); ok { | |
| 216 | + | r0 = returnFunc(ctx, n, r, size) | |
| 217 | + | } else { | |
| 218 | + | r0 = ret.Error(0) | |
| 219 | + | } | |
| 220 | + | return r0 | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // MultipartUpload_UploadPart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UploadPart' | |
| 224 | + | type MultipartUpload_UploadPart_Call struct { | |
| 225 | + | *mock.Call | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // UploadPart is a helper method to define mock.On call | |
| 229 | + | // - ctx context.Context | |
| 230 | + | // - n int | |
| 231 | + | // - r io.Reader | |
| 232 | + | // - size int64 | |
| 233 | + | func (_e *MultipartUpload_Expecter) UploadPart(ctx any, n any, r any, size any) *MultipartUpload_UploadPart_Call { | |
| 234 | + | return &MultipartUpload_UploadPart_Call{Call: _e.mock.On("UploadPart", ctx, n, r, size)} | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func (_c *MultipartUpload_UploadPart_Call) Run(run func(ctx context.Context, n int, r io.Reader, size int64)) *MultipartUpload_UploadPart_Call { | |
| 238 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 239 | + | var arg0 context.Context | |
| 240 | + | if args[0] != nil { | |
| 241 | + | arg0 = args[0].(context.Context) | |
| 242 | + | } | |
| 243 | + | var arg1 int | |
| 244 | + | if args[1] != nil { | |
| 245 | + | arg1 = args[1].(int) | |
| 246 | + | } | |
| 247 | + | var arg2 io.Reader | |
| 248 | + | if args[2] != nil { | |
| 249 | + | arg2 = args[2].(io.Reader) | |
| 250 | + | } | |
| 251 | + | var arg3 int64 | |
| 252 | + | if args[3] != nil { | |
| 253 | + | arg3 = args[3].(int64) | |
| 254 | + | } | |
| 255 | + | run( | |
| 256 | + | arg0, | |
| 257 | + | arg1, | |
| 258 | + | arg2, | |
| 259 | + | arg3, | |
| 260 | + | ) | |
| 261 | + | }) | |
| 262 | + | return _c | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func (_c *MultipartUpload_UploadPart_Call) Return(err error) *MultipartUpload_UploadPart_Call { | |
| 266 | + | _c.Call.Return(err) | |
| 267 | + | return _c | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func (_c *MultipartUpload_UploadPart_Call) RunAndReturn(run func(ctx context.Context, n int, r io.Reader, size int64) error) *MultipartUpload_UploadPart_Call { | |
| 271 | + | _c.Call.Return(run) | |
| 272 | + | return _c | |
| 273 | + | } | |
| @@ -0,0 +1,273 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | "io" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/ermite/internal/ports" | |||
| 12 | + | mock "github.com/stretchr/testify/mock" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // NewMultipartUpload creates a new instance of MultipartUpload. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 16 | + | // The first argument is typically a *testing.T value. | |||
| 17 | + | func NewMultipartUpload(t interface { | |||
| 18 | + | mock.TestingT | |||
| 19 | + | Cleanup(func()) | |||
| 20 | + | }) *MultipartUpload { | |||
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 22 | + | helper.Helper() | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | mock := &MultipartUpload{} | |||
| 26 | + | mock.Mock.Test(t) | |||
| 27 | + | ||||
| 28 | + | t.Cleanup(func() { | |||
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 30 | + | helper.Helper() | |||
| 31 | + | } | |||
| 32 | + | mock.AssertExpectations(t) | |||
| 33 | + | }) | |||
| 34 | + | ||||
| 35 | + | return mock | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // MultipartUpload is an autogenerated mock type for the MultipartUpload type | |||
| 39 | + | type MultipartUpload struct { | |||
| 40 | + | mock.Mock | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type MultipartUpload_Expecter struct { | |||
| 44 | + | mock *mock.Mock | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (_m *MultipartUpload) EXPECT() *MultipartUpload_Expecter { | |||
| 48 | + | return &MultipartUpload_Expecter{mock: &_m.Mock} | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Abort provides a mock function for the type MultipartUpload | |||
| 52 | + | func (_mock *MultipartUpload) Abort(ctx context.Context) error { | |||
| 53 | + | ret := _mock.Called(ctx) | |||
| 54 | + | ||||
| 55 | + | if len(ret) == 0 { | |||
| 56 | + | panic("no return value specified for Abort") | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | var r0 error | |||
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { | |||
| 61 | + | r0 = returnFunc(ctx) | |||
| 62 | + | } else { | |||
| 63 | + | r0 = ret.Error(0) | |||
| 64 | + | } | |||
| 65 | + | return r0 | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // MultipartUpload_Abort_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Abort' | |||
| 69 | + | type MultipartUpload_Abort_Call struct { | |||
| 70 | + | *mock.Call | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // Abort is a helper method to define mock.On call | |||
| 74 | + | // - ctx context.Context | |||
| 75 | + | func (_e *MultipartUpload_Expecter) Abort(ctx any) *MultipartUpload_Abort_Call { | |||
| 76 | + | return &MultipartUpload_Abort_Call{Call: _e.mock.On("Abort", ctx)} | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (_c *MultipartUpload_Abort_Call) Run(run func(ctx context.Context)) *MultipartUpload_Abort_Call { | |||
| 80 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 81 | + | var arg0 context.Context | |||
| 82 | + | if args[0] != nil { | |||
| 83 | + | arg0 = args[0].(context.Context) | |||
| 84 | + | } | |||
| 85 | + | run( | |||
| 86 | + | arg0, | |||
| 87 | + | ) | |||
| 88 | + | }) | |||
| 89 | + | return _c | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func (_c *MultipartUpload_Abort_Call) Return(err error) *MultipartUpload_Abort_Call { | |||
| 93 | + | _c.Call.Return(err) | |||
| 94 | + | return _c | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func (_c *MultipartUpload_Abort_Call) RunAndReturn(run func(ctx context.Context) error) *MultipartUpload_Abort_Call { | |||
| 98 | + | _c.Call.Return(run) | |||
| 99 | + | return _c | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // Complete provides a mock function for the type MultipartUpload | |||
| 103 | + | func (_mock *MultipartUpload) Complete(ctx context.Context) (ports.BlobInfo, error) { | |||
| 104 | + | ret := _mock.Called(ctx) | |||
| 105 | + | ||||
| 106 | + | if len(ret) == 0 { | |||
| 107 | + | panic("no return value specified for Complete") | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | var r0 ports.BlobInfo | |||
| 111 | + | var r1 error | |||
| 112 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (ports.BlobInfo, error)); ok { | |||
| 113 | + | return returnFunc(ctx) | |||
| 114 | + | } | |||
| 115 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ports.BlobInfo); ok { | |||
| 116 | + | r0 = returnFunc(ctx) | |||
| 117 | + | } else { | |||
| 118 | + | r0 = ret.Get(0).(ports.BlobInfo) | |||
| 119 | + | } | |||
| 120 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 121 | + | r1 = returnFunc(ctx) | |||
| 122 | + | } else { | |||
| 123 | + | r1 = ret.Error(1) | |||
| 124 | + | } | |||
| 125 | + | return r0, r1 | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // MultipartUpload_Complete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Complete' | |||
| 129 | + | type MultipartUpload_Complete_Call struct { | |||
| 130 | + | *mock.Call | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // Complete is a helper method to define mock.On call | |||
| 134 | + | // - ctx context.Context | |||
| 135 | + | func (_e *MultipartUpload_Expecter) Complete(ctx any) *MultipartUpload_Complete_Call { | |||
| 136 | + | return &MultipartUpload_Complete_Call{Call: _e.mock.On("Complete", ctx)} | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func (_c *MultipartUpload_Complete_Call) Run(run func(ctx context.Context)) *MultipartUpload_Complete_Call { | |||
| 140 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 141 | + | var arg0 context.Context | |||
| 142 | + | if args[0] != nil { | |||
| 143 | + | arg0 = args[0].(context.Context) | |||
| 144 | + | } | |||
| 145 | + | run( | |||
| 146 | + | arg0, | |||
| 147 | + | ) | |||
| 148 | + | }) | |||
| 149 | + | return _c | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func (_c *MultipartUpload_Complete_Call) Return(blobInfo ports.BlobInfo, err error) *MultipartUpload_Complete_Call { | |||
| 153 | + | _c.Call.Return(blobInfo, err) | |||
| 154 | + | return _c | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func (_c *MultipartUpload_Complete_Call) RunAndReturn(run func(ctx context.Context) (ports.BlobInfo, error)) *MultipartUpload_Complete_Call { | |||
| 158 | + | _c.Call.Return(run) | |||
| 159 | + | return _c | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // ID provides a mock function for the type MultipartUpload | |||
| 163 | + | func (_mock *MultipartUpload) ID() string { | |||
| 164 | + | ret := _mock.Called() | |||
| 165 | + | ||||
| 166 | + | if len(ret) == 0 { | |||
| 167 | + | panic("no return value specified for ID") | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | var r0 string | |||
| 171 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |||
| 172 | + | r0 = returnFunc() | |||
| 173 | + | } else { | |||
| 174 | + | r0 = ret.Get(0).(string) | |||
| 175 | + | } | |||
| 176 | + | return r0 | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // MultipartUpload_ID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ID' | |||
| 180 | + | type MultipartUpload_ID_Call struct { | |||
| 181 | + | *mock.Call | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // ID is a helper method to define mock.On call | |||
| 185 | + | func (_e *MultipartUpload_Expecter) ID() *MultipartUpload_ID_Call { | |||
| 186 | + | return &MultipartUpload_ID_Call{Call: _e.mock.On("ID")} | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func (_c *MultipartUpload_ID_Call) Run(run func()) *MultipartUpload_ID_Call { | |||
| 190 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 191 | + | run() | |||
| 192 | + | }) | |||
| 193 | + | return _c | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func (_c *MultipartUpload_ID_Call) Return(s string) *MultipartUpload_ID_Call { | |||
| 197 | + | _c.Call.Return(s) | |||
| 198 | + | return _c | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func (_c *MultipartUpload_ID_Call) RunAndReturn(run func() string) *MultipartUpload_ID_Call { | |||
| 202 | + | _c.Call.Return(run) | |||
| 203 | + | return _c | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // UploadPart provides a mock function for the type MultipartUpload | |||
| 207 | + | func (_mock *MultipartUpload) UploadPart(ctx context.Context, n int, r io.Reader, size int64) error { | |||
| 208 | + | ret := _mock.Called(ctx, n, r, size) | |||
| 209 | + | ||||
| 210 | + | if len(ret) == 0 { | |||
| 211 | + | panic("no return value specified for UploadPart") | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | var r0 error | |||
| 215 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int, io.Reader, int64) error); ok { | |||
| 216 | + | r0 = returnFunc(ctx, n, r, size) | |||
| 217 | + | } else { | |||
| 218 | + | r0 = ret.Error(0) | |||
| 219 | + | } | |||
| 220 | + | return r0 | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // MultipartUpload_UploadPart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UploadPart' | |||
| 224 | + | type MultipartUpload_UploadPart_Call struct { | |||
| 225 | + | *mock.Call | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | // UploadPart is a helper method to define mock.On call | |||
| 229 | + | // - ctx context.Context | |||
| 230 | + | // - n int | |||
| 231 | + | // - r io.Reader | |||
| 232 | + | // - size int64 | |||
| 233 | + | func (_e *MultipartUpload_Expecter) UploadPart(ctx any, n any, r any, size any) *MultipartUpload_UploadPart_Call { | |||
| 234 | + | return &MultipartUpload_UploadPart_Call{Call: _e.mock.On("UploadPart", ctx, n, r, size)} | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | func (_c *MultipartUpload_UploadPart_Call) Run(run func(ctx context.Context, n int, r io.Reader, size int64)) *MultipartUpload_UploadPart_Call { | |||
| 238 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 239 | + | var arg0 context.Context | |||
| 240 | + | if args[0] != nil { | |||
| 241 | + | arg0 = args[0].(context.Context) | |||
| 242 | + | } | |||
| 243 | + | var arg1 int | |||
| 244 | + | if args[1] != nil { | |||
| 245 | + | arg1 = args[1].(int) | |||
| 246 | + | } | |||
| 247 | + | var arg2 io.Reader | |||
| 248 | + | if args[2] != nil { | |||
| 249 | + | arg2 = args[2].(io.Reader) | |||
| 250 | + | } | |||
| 251 | + | var arg3 int64 | |||
| 252 | + | if args[3] != nil { | |||
| 253 | + | arg3 = args[3].(int64) | |||
| 254 | + | } | |||
| 255 | + | run( | |||
| 256 | + | arg0, | |||
| 257 | + | arg1, | |||
| 258 | + | arg2, | |||
| 259 | + | arg3, | |||
| 260 | + | ) | |||
| 261 | + | }) | |||
| 262 | + | return _c | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | func (_c *MultipartUpload_UploadPart_Call) Return(err error) *MultipartUpload_UploadPart_Call { | |||
| 266 | + | _c.Call.Return(err) | |||
| 267 | + | return _c | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func (_c *MultipartUpload_UploadPart_Call) RunAndReturn(run func(ctx context.Context, n int, r io.Reader, size int64) error) *MultipartUpload_UploadPart_Call { | |||
| 271 | + | _c.Call.Return(run) | |||
| 272 | + | return _c | |||
| 273 | + | } | |||