Skip to content

Commit f135622

Browse files
authored
Various S3 Fixes (#10)
Various S3 fixes around marker file handling: - Deletion and overwrite now also triggers a marker update - Require the protocol in the option's URL
1 parent a779f8f commit f135622

File tree

5 files changed

+144
-70
lines changed

5 files changed

+144
-70
lines changed

backends/s3/marker.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package s3
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
)
8+
9+
// setMarker puts name and etag into the object identified by
10+
// UpdateMarkerFilename.
11+
// An empty etag string means that the object identified by name was deleted.
12+
//
13+
// In case the UseUpdateMarker option is false, this function doesn't do
14+
// anything and returns no error.
15+
func (b *Backend) setMarker(ctx context.Context, name, etag string, isDel bool) error {
16+
if !b.opt.UseUpdateMarker {
17+
return nil
18+
}
19+
nanos := time.Now().UnixNano()
20+
s := fmt.Sprintf("%s:%s:%d:%v", name, etag, nanos, isDel)
21+
_, err := b.doStore(ctx, UpdateMarkerFilename, []byte(s))
22+
if err != nil {
23+
return err
24+
}
25+
b.mu.Lock()
26+
defer b.mu.Unlock()
27+
b.lastList = nil
28+
b.lastMarker = s
29+
return nil
30+
}

backends/s3/s3.go

Lines changed: 60 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package s3
33
import (
44
"bytes"
55
"context"
6+
"errors"
67
"fmt"
78
"io"
89
"net/url"
@@ -14,8 +15,8 @@ import (
1415
"time"
1516

1617
"github.com/PowerDNS/go-tlsconfig"
18+
"github.com/minio/minio-go/v7"
1719
"github.com/go-logr/logr"
18-
minio "github.com/minio/minio-go/v7"
1920
"github.com/minio/minio-go/v7/pkg/credentials"
2021

2122
"github.com/PowerDNS/simpleblob"
@@ -24,7 +25,7 @@ import (
2425
const (
2526
// DefaultEndpointURL is the default S3 endpoint to use if none is set.
2627
// Here, no custom endpoint assumes AWS endpoint.
27-
DefaultEndpointURL = "s3.amazonaws.com"
28+
DefaultEndpointURL = "https://s3.amazonaws.com"
2829
// DefaultRegion is the default S3 region to use, if none is configured
2930
DefaultRegion = "us-east-1"
3031
// DefaultInitTimeout is the time we allow for initialisation, like credential
@@ -51,7 +52,7 @@ type Options struct {
5152
CreateBucket bool `yaml:"create_bucket"`
5253

5354
// EndpointURL can be set to something like "http://localhost:9000" when using Minio
54-
// or "s3.amazonaws.com" for AWS S3.
55+
// or "https://s3.amazonaws.com" for AWS S3.
5556
EndpointURL string `yaml:"endpoint_url"`
5657

5758
// TLS allows customising the TLS configuration
@@ -65,7 +66,7 @@ type Options struct {
6566

6667
// UseUpdateMarker makes the backend write and read a file to determine if
6768
// it can cache the last List command. The file contains the name of the
68-
// last file stored.
69+
// last file stored or deleted.
6970
// This can reduce the number of LIST commands sent to S3, replacing them
7071
// with GET commands that are about 12x cheaper.
7172
// If enabled, it MUST be enabled on all instances!
@@ -113,36 +114,36 @@ func (b *Backend) List(ctx context.Context, prefix string) (simpleblob.BlobList,
113114
return b.doList(ctx, prefix)
114115
}
115116

116-
// Request and cache full list, and use marker file to invalidate the cache
117-
now := time.Now()
118-
data, err := b.Load(ctx, UpdateMarkerFilename)
119-
if err != nil && !os.IsNotExist(err) {
120-
return nil, err
117+
m, err := b.Load(ctx, UpdateMarkerFilename)
118+
exists := !errors.Is(err, os.ErrNotExist)
119+
if err != nil && exists {
120+
return nil, err
121121
}
122-
current := string(data)
122+
upstreamMarker := string(m)
123123

124124
b.mu.Lock()
125-
lastMarker := b.lastMarker
126-
age := now.Sub(b.lastTime)
125+
mustUpdate := b.lastList == nil ||
126+
upstreamMarker != b.lastMarker ||
127+
time.Since(b.lastTime) >= b.opt.UpdateMarkerForceListInterval ||
128+
!exists
129+
blobs := b.lastList
127130
b.mu.Unlock()
128131

129-
var blobs simpleblob.BlobList
130-
if current != lastMarker || age >= b.opt.UpdateMarkerForceListInterval {
131-
// Update cache
132-
blobs, err = b.doList(ctx, "") // all, no prefix
133-
if err != nil {
134-
return nil, err
135-
}
132+
if !mustUpdate {
133+
return blobs.WithPrefix(prefix), nil
134+
}
136135

137-
b.mu.Lock()
138-
b.lastMarker = current
139-
b.lastList = blobs
140-
b.mu.Unlock()
141-
} else {
142-
b.mu.Lock()
143-
blobs = b.lastList
144-
b.mu.Unlock()
136+
blobs, err = b.doList(ctx, "") // We want to cache all, so no prefix
137+
if err != nil {
138+
return nil, err
145139
}
140+
141+
b.mu.Lock()
142+
b.lastMarker = upstreamMarker
143+
b.lastList = blobs
144+
b.lastTime = time.Now()
145+
b.mu.Unlock()
146+
146147
return blobs.WithPrefix(prefix), nil
147148
}
148149

@@ -169,57 +170,64 @@ func (b *Backend) doList(ctx context.Context, prefix string) (simpleblob.BlobLis
169170
return blobs, nil
170171
}
171172

173+
// Load retrieves the content of the object identified by name from S3 Bucket
174+
// configured in b.
172175
func (b *Backend) Load(ctx context.Context, name string) ([]byte, error) {
173176
metricCalls.WithLabelValues("load").Inc()
174177
metricLastCallTimestamp.WithLabelValues("load").SetToCurrentTime()
175178

176179
obj, err := b.client.GetObject(ctx, b.opt.Bucket, name, minio.GetObjectOptions{})
177-
if err != nil {
178-
if err = handleErrorResponse(err); err != nil {
179-
return nil, err
180-
}
180+
if err = convertMinioError(err); err != nil {
181+
return nil, err
181182
} else if obj == nil {
182183
return nil, os.ErrNotExist
183184
}
184185

185186
p, err := io.ReadAll(obj)
186-
if err = handleErrorResponse(err); err != nil {
187+
if err = convertMinioError(err); err != nil {
187188
return nil, err
188189
}
189190
return p, nil
190191
}
191192

193+
// Store sets the content of the object identified by name to the content
194+
// of data, in the S3 Bucket configured in b.
192195
func (b *Backend) Store(ctx context.Context, name string, data []byte) error {
193-
if err := b.doStore(ctx, name, data); err != nil {
196+
info, err := b.doStore(ctx, name, data)
197+
if err != nil {
194198
return err
195199
}
196-
if b.opt.UseUpdateMarker {
197-
if err := b.doStore(ctx, UpdateMarkerFilename, []byte(name)); err != nil {
198-
return err
199-
}
200-
}
201-
return nil
200+
return b.setMarker(ctx, name, info.ETag, false)
202201
}
203202

204-
func (b *Backend) doStore(ctx context.Context, name string, data []byte) error {
203+
func (b *Backend) doStore(ctx context.Context, name string, data []byte) (minio.UploadInfo, error) {
205204
metricCalls.WithLabelValues("store").Inc()
206205
metricLastCallTimestamp.WithLabelValues("store").SetToCurrentTime()
207206

208-
_, err := b.client.PutObject(ctx, b.opt.Bucket, name, bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{
207+
info, err := b.client.PutObject(ctx, b.opt.Bucket, name, bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{
209208
NumThreads: 3,
210209
})
211210
if err != nil {
212211
metricCallErrors.WithLabelValues("store").Inc()
213212
}
214-
return err
213+
return info, err
215214
}
216215

216+
// Delete removes the object identified by name from the S3 Bucket
217+
// configured in b.
217218
func (b *Backend) Delete(ctx context.Context, name string) error {
219+
if err := b.doDelete(ctx, name); err != nil {
220+
return err
221+
}
222+
return b.setMarker(ctx, name, "", true)
223+
}
224+
225+
func (b *Backend) doDelete(ctx context.Context, name string) error {
218226
metricCalls.WithLabelValues("delete").Inc()
219227
metricLastCallTimestamp.WithLabelValues("delete").SetToCurrentTime()
220228

221229
err := b.client.RemoveObject(ctx, b.opt.Bucket, name, minio.RemoveObjectOptions{})
222-
if err = handleErrorResponse(err); err != nil {
230+
if err = convertMinioError(err); err != nil {
223231
metricCallErrors.WithLabelValues("delete").Inc()
224232
}
225233
return err
@@ -288,8 +296,10 @@ func New(ctx context.Context, opt Options) (*Backend, error) {
288296
// Ok, no SSL
289297
case "https":
290298
useSSL = true
299+
case "":
300+
return nil, fmt.Errorf("no scheme provided for endpoint URL '%s', use http or https.", opt.EndpointURL)
291301
default:
292-
return nil, fmt.Errorf("unsupported scheme for S3: '%s'", u.Scheme)
302+
return nil, fmt.Errorf("unsupported scheme for S3: '%s', use http or https.", u.Scheme)
293303
}
294304

295305
cfg := &minio.Options{
@@ -320,7 +330,7 @@ func New(ctx context.Context, opt Options) (*Backend, error) {
320330

321331
err := client.MakeBucket(ctx, opt.Bucket, minio.MakeBucketOptions{Region: opt.Region})
322332
if err != nil {
323-
if err := handleErrorResponse(err); err != nil {
333+
if err := convertMinioError(err); err != nil {
324334
return nil, err
325335
}
326336
}
@@ -336,11 +346,14 @@ func New(ctx context.Context, opt Options) (*Backend, error) {
336346
return b, nil
337347
}
338348

339-
// handleErrorResponse takes an error, possibly a minio.ErrorResponse
349+
// convertMinioError takes an error, possibly a minio.ErrorResponse
340350
// and turns it into a well known error when possible.
341351
// If error is not well known, it is returned as is.
342352
// If error is considered to be ignorable, nil is returned.
343-
func handleErrorResponse(err error) error {
353+
func convertMinioError(err error) error {
354+
if err == nil {
355+
return nil
356+
}
344357
errResp := minio.ToErrorResponse(err)
345358
if errResp.StatusCode == 404 {
346359
return os.ErrNotExist

backends/s3/s3_test.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ func getBackend(ctx context.Context, t *testing.T) (b *Backend) {
5151
require.NoError(t, err)
5252

5353
cleanup := func() {
54+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
55+
defer cancel()
56+
5457
blobs, err := b.doList(ctx, "")
5558
if err != nil {
5659
t.Logf("Blobs list error: %s", err)
@@ -74,24 +77,27 @@ func getBackend(ctx context.Context, t *testing.T) (b *Backend) {
7477

7578
func TestBackend(t *testing.T) {
7679
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
77-
t.Cleanup(cancel)
80+
defer cancel()
7881

7982
b := getBackend(ctx, t)
8083
tester.DoBackendTests(t, b)
81-
assert.Equal(t, "", b.lastMarker)
84+
assert.Len(t, b.lastMarker, 0)
8285
}
8386

8487
func TestBackend_marker(t *testing.T) {
8588
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
86-
t.Cleanup(cancel)
89+
defer cancel()
8790

8891
b := getBackend(ctx, t)
8992
b.opt.UseUpdateMarker = true
9093

9194
tester.DoBackendTests(t, b)
92-
assert.Equal(t, "bar-1", b.lastMarker)
95+
assert.Regexp(t, "^foo-1:[A-Za-z0-9]*:[0-9]+:true$", b.lastMarker)
96+
// ^ reflects last write operation of tester.DoBackendTests
97+
// i.e. deleting "foo-1"
9398

94-
data, err := b.Load(ctx, UpdateMarkerFilename)
99+
// Marker file should have been written accordingly
100+
markerFileContent, err := b.Load(ctx, UpdateMarkerFilename)
95101
assert.NoError(t, err)
96-
assert.Equal(t, "bar-1", string(data))
102+
assert.EqualValues(t, b.lastMarker, markerFileContent)
97103
}

test.sh

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,33 +3,49 @@
33
set -e
44

55
# Stop all child processes on exit
6-
trap "pkill -P $$" EXIT
6+
trap cleanup EXIT
7+
8+
cleanup() {
9+
pkill -P $$
10+
if [ -n "$tmpdir" ] && [ -d "$tmpdir" ]; then
11+
rm -r "$tmpdir"
12+
fi
13+
}
714

815
export GOBIN="$PWD/bin"
16+
export PATH="$GOBIN:$PATH"
17+
tmpdir=
18+
19+
mkdir -p "$GOBIN"
920

1021
if [ -z "$SIMPLEBLOB_TEST_S3_CONFIG" ]; then
1122
echo "* Using MinIO for S3 tests"
1223
export SIMPLEBLOB_TEST_S3_CONFIG="$PWD/test-minio.json"
1324

14-
# Check for existing minio
15-
minio=$(which minio || true)
16-
1725
# Fetch minio if not found
18-
if [ -z "$minio" ]; then
19-
#minioversion="github.com/minio/[email protected]"
20-
#echo "+ go install $minioversion" > /dev/stderr
21-
#go install "$minioversion"
26+
if ! command -v minio >/dev/null; then
2227
source <(go env)
23-
curl -v -o "$GOBIN/minio" "https://dl.min.io/server/minio/release/$GOOS-$GOARCH/minio"
24-
minio=./bin/minio
25-
chmod 6755 "$minio"
28+
dst="$GOBIN/minio"
29+
curl -v -o "$dst" "https://dl.min.io/server/minio/release/$GOOS-$GOARCH/minio"
30+
chmod u+x "$dst"
2631
fi
2732

2833
# Start MinIO
29-
echo "* Starting $minio on port 34730"
34+
echo "* Starting minio at address 127.0.0.1:34730"
3035
tmpdir=$(mktemp -d -t minio.XXXXXX)
31-
"$minio" server --address 127.0.0.1:34730 --console-address 127.0.0.1:34731 --quiet "$tmpdir" &
32-
sleep 3
36+
minio server --address 127.0.0.1:34730 --console-address 127.0.0.1:34731 --quiet "$tmpdir" &
37+
# Wait for minio server to be ready
38+
i=0
39+
while ! curl -s -I "127.0.0.1:34730/minio/health/ready" | grep '200 OK' >/dev/null; do
40+
i=$((i+1))
41+
if [ "$i" -ge 10 ]; then
42+
# We have been waiting for server to start for 10 seconds
43+
echo "Minio could not start properly"
44+
curl -s -I "127.0.0.1:34730/minio/health/ready"
45+
exit 1
46+
fi
47+
sleep 1
48+
done
3349
fi
3450

3551
echo "* SIMPLEBLOB_TEST_S3_CONFIG=$SIMPLEBLOB_TEST_S3_CONFIG"
@@ -39,6 +55,6 @@ set -ex
3955
go test -count=1 "$@" ./...
4056

4157
# Configure linters in .golangci.yml
42-
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.50.1
43-
./bin/golangci-lint run
44-
58+
expected_version=v1.50.1
59+
go install github.com/golangci/golangci-lint/cmd/golangci-lint@"$expected_version"
60+
golangci-lint run

tester/tester.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,13 @@ func DoBackendTests(t *testing.T, b simpleblob.Interface) {
7979
// Delete non-existing
8080
err = b.Delete(ctx, "foo-1")
8181
assert.NoError(t, err)
82+
83+
// Should not exist anymore
84+
_, err = b.Load(ctx, "foo-1")
85+
assert.ErrorIs(t, err, os.ErrNotExist)
86+
87+
// Should not appear in list anymore
88+
ls, err = b.List(ctx, "")
89+
assert.NoError(t, err)
90+
assert.NotContains(t, ls.Names(), "foo-1")
8291
}

0 commit comments

Comments
 (0)