mirror of
https://github.com/versity/versitygw.git
synced 2026-09-23 08:24:17 +00:00
* feat(azure): carry Azure blob marker in continuation token for pagination Azure blob markers are opaque values only Azure may mint, so an S3 object key can never be passed back to Azure as a listing marker. Paging the underlying Azure listing with an S3 key therefore failed or re-scanned the whole prefix on every page. Introduce azMarkerToken, an opaque S3 continuation token that carries the Azure marker alongside the last returned key: the Azure marker resumes the blob listing where it stopped, and the last key filters out already-returned entries. Tokens are versioned with a "vgw1." prefix; anything without it is treated as a plain key, so tokens from older versions and hand-crafted markers keep working. The shared listBlobs helper now backs both ListObjects and ListObjectsV2, applying the S3 marker and delimiter client side. ListObjectsV2 pages efficiently via the token; ListObjects (v1) has no token to carry state and walks the prefix from the start each page. Add unit tests (token round-trip, marker-resumed pagination, delimiter and common-prefix handling, multipart filtering) driven by a fake Azure container, and integration tests covering full and delimited pagination. Signed-off-by: Nils Leger <nils.leger@getflip.com> * fix: token is now bound to delimiter too Signed-off-by: Nils Leger <nils.leger@getflip.com> --------- Signed-off-by: Nils Leger <nils.leger@getflip.com>
325 lines
8.8 KiB
Go
325 lines
8.8 KiB
Go
// Copyright 2026 Versity Software
|
|
// This file is licensed under the Apache License, Version 2.0
|
|
// (the "License"); you may not use this file except in compliance
|
|
// with the License. You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing,
|
|
// software distributed under the License is distributed on an
|
|
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
// KIND, either express or implied. See the License for the
|
|
// specific language governing permissions and limitations
|
|
// under the License.
|
|
|
|
package azure
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container"
|
|
"github.com/versity/versitygw/backend"
|
|
)
|
|
|
|
func TestDecodeAzMarkerToken(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
token string
|
|
prefix string
|
|
delimiter string
|
|
wantMarker string
|
|
wantKey string
|
|
}{
|
|
{
|
|
name: "round trip",
|
|
token: encodeAzMarkerToken(azMarkerToken{Prefix: "test/", Delimiter: "/", Marker: "2!68!MDAwMDI4", LastKey: "test/a.js"}),
|
|
prefix: "test/",
|
|
delimiter: "/",
|
|
wantMarker: "2!68!MDAwMDI4",
|
|
wantKey: "test/a.js",
|
|
},
|
|
{
|
|
name: "plain key from an older token or a hand written marker",
|
|
token: "test/a.js",
|
|
prefix: "test/",
|
|
wantKey: "test/a.js",
|
|
},
|
|
{
|
|
// the Azure marker belongs to the listing that produced it, so only
|
|
// the key survives a prefix change
|
|
name: "prefix mismatch drops the azure marker",
|
|
token: encodeAzMarkerToken(azMarkerToken{Prefix: "test/", Marker: "2!68!MDAwMDI4", LastKey: "test/a.js"}),
|
|
prefix: "media/",
|
|
wantKey: "test/a.js",
|
|
},
|
|
{
|
|
// the last key was collapsed under the token's delimiter, so a
|
|
// delimiter change drops the azure marker and restarts the listing
|
|
name: "delimiter mismatch drops the azure marker",
|
|
token: encodeAzMarkerToken(azMarkerToken{Prefix: "test/", Delimiter: "/", Marker: "2!68!MDAwMDI4", LastKey: "test/a.js"}),
|
|
prefix: "test/",
|
|
delimiter: "",
|
|
wantKey: "test/a.js",
|
|
},
|
|
{
|
|
name: "corrupt token",
|
|
token: azTokenPrefix + "!!!not base64!!!",
|
|
prefix: "test/",
|
|
wantKey: azTokenPrefix + "!!!not base64!!!",
|
|
},
|
|
{
|
|
name: "empty token",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
marker, key := decodeAzMarkerToken(tt.token, tt.prefix, tt.delimiter)
|
|
if marker != tt.wantMarker {
|
|
t.Errorf("azure marker: got %q, want %q", marker, tt.wantMarker)
|
|
}
|
|
if key != tt.wantKey {
|
|
t.Errorf("last key: got %q, want %q", key, tt.wantKey)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListBlobsPagination(t *testing.T) {
|
|
keys := make([]string, 0, 2500)
|
|
for i := range 2500 {
|
|
keys = append(keys, fmt.Sprintf("test/asset-%04d.js", i))
|
|
}
|
|
|
|
client, srv := fakeAzureContainer(t, keys, 1000)
|
|
|
|
var got []string
|
|
var marker, azureMarker string
|
|
for page := 0; ; page++ {
|
|
if page > 10 {
|
|
t.Fatal("listing did not terminate")
|
|
}
|
|
|
|
res, err := (&Azure{}).listBlobs(context.Background(), client, azListingOpts{
|
|
prefix: "test/",
|
|
marker: marker,
|
|
azureMarker: azureMarker,
|
|
maxKeys: 1000,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list blobs: %v", err)
|
|
}
|
|
|
|
for _, o := range res.objects {
|
|
got = append(got, backend.GetStringFromPtr(o.Key))
|
|
}
|
|
if !res.isTruncated {
|
|
break
|
|
}
|
|
if len(res.objects) != 1000 {
|
|
t.Fatalf("truncated page holds %v objects, want 1000", len(res.objects))
|
|
}
|
|
marker, azureMarker = res.lastKey, res.resumeMarker
|
|
}
|
|
|
|
if len(got) != len(keys) {
|
|
t.Fatalf("listed %v objects, want %v", len(got), len(keys))
|
|
}
|
|
for i, key := range keys {
|
|
if got[i] != key {
|
|
t.Fatalf("object %v: got %q, want %q", i, got[i], key)
|
|
}
|
|
}
|
|
|
|
// resuming from an Azure marker must keep the number of blob listings
|
|
// linear in the number of objects
|
|
if srv.requests > 5 {
|
|
t.Errorf("listing took %v azure requests, want at most 5", srv.requests)
|
|
}
|
|
}
|
|
|
|
func TestListBlobsMarkerAndDelimiter(t *testing.T) {
|
|
keys := []string{
|
|
"test/a/1.js",
|
|
"test/a/2.js",
|
|
"test/b/1.js",
|
|
"test/root.js",
|
|
"test/z/1.js",
|
|
}
|
|
client, _ := fakeAzureContainer(t, keys, 1000)
|
|
|
|
res, err := (&Azure{}).listBlobs(context.Background(), client, azListingOpts{
|
|
prefix: "test/",
|
|
delimiter: "/",
|
|
marker: "test/a/",
|
|
maxKeys: 1000,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list blobs: %v", err)
|
|
}
|
|
|
|
var cps []string
|
|
for _, cp := range res.commonPrefixes {
|
|
cps = append(cps, backend.GetStringFromPtr(cp.Prefix))
|
|
}
|
|
if strings.Join(cps, ",") != "test/b/,test/z/" {
|
|
t.Errorf("common prefixes: got %v, want [test/b/ test/z/]", cps)
|
|
}
|
|
if len(res.objects) != 1 || backend.GetStringFromPtr(res.objects[0].Key) != "test/root.js" {
|
|
t.Errorf("objects: got %v, want [test/root.js]", res.objects)
|
|
}
|
|
if res.isTruncated {
|
|
t.Error("listing reported as truncated")
|
|
}
|
|
}
|
|
|
|
func TestListBlobsTruncatedCommonPrefixes(t *testing.T) {
|
|
// common prefixes deliberately span Azure page boundaries so paging must
|
|
// resume from an Azure marker and dedupe prefixes both within a page and
|
|
// across the truncation boundary
|
|
keys := []string{
|
|
"test/a/1.js",
|
|
"test/a/2.js",
|
|
"test/b/1.js",
|
|
"test/c/1.js",
|
|
"test/d/1.js",
|
|
}
|
|
client, _ := fakeAzureContainer(t, keys, 2)
|
|
|
|
var got []string
|
|
var marker, azureMarker string
|
|
for page := 0; ; page++ {
|
|
if page > 10 {
|
|
t.Fatal("listing did not terminate")
|
|
}
|
|
|
|
res, err := (&Azure{}).listBlobs(context.Background(), client, azListingOpts{
|
|
prefix: "test/",
|
|
delimiter: "/",
|
|
marker: marker,
|
|
azureMarker: azureMarker,
|
|
maxKeys: 2,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list blobs: %v", err)
|
|
}
|
|
if len(res.objects) != 0 {
|
|
t.Fatalf("expected no objects, got %v", res.objects)
|
|
}
|
|
|
|
for _, cp := range res.commonPrefixes {
|
|
got = append(got, backend.GetStringFromPtr(cp.Prefix))
|
|
}
|
|
if !res.isTruncated {
|
|
break
|
|
}
|
|
marker, azureMarker = res.lastKey, res.resumeMarker
|
|
}
|
|
|
|
want := []string{"test/a/", "test/b/", "test/c/", "test/d/"}
|
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("common prefixes: got %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestListBlobsSkipsMultipartUploads(t *testing.T) {
|
|
// multipart staging blobs must be filtered out before they count against
|
|
// maxKeys, otherwise the listing would truncate early and hide real objects
|
|
keys := []string{
|
|
string(metaTmpMultipartPrefix) + "/upload-1/part",
|
|
string(metaTmpMultipartPrefix) + "/upload-2/part",
|
|
"a.js",
|
|
"b.js",
|
|
}
|
|
client, _ := fakeAzureContainer(t, keys, 1000)
|
|
|
|
res, err := (&Azure{}).listBlobs(context.Background(), client, azListingOpts{
|
|
maxKeys: 2,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list blobs: %v", err)
|
|
}
|
|
|
|
var got []string
|
|
for _, o := range res.objects {
|
|
got = append(got, backend.GetStringFromPtr(o.Key))
|
|
}
|
|
if strings.Join(got, ",") != "a.js,b.js" {
|
|
t.Errorf("objects: got %v, want [a.js b.js]", got)
|
|
}
|
|
if res.isTruncated {
|
|
t.Error("listing reported as truncated; multipart blobs counted against maxKeys")
|
|
}
|
|
}
|
|
|
|
type fakeAzureServer struct {
|
|
requests int
|
|
}
|
|
|
|
// fakeAzureContainer serves blob listings the way Azure does: paged with opaque
|
|
// markers that only it mints, rejecting anything else with the same error real
|
|
// Azure returns for an S3 key passed as marker.
|
|
func fakeAzureContainer(t *testing.T, keys []string, pageSize int) (*container.Client, *fakeAzureServer) {
|
|
t.Helper()
|
|
|
|
state := &fakeAzureServer{}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
state.requests++
|
|
|
|
q := r.URL.Query()
|
|
prefix := q.Get("prefix")
|
|
|
|
start := 0
|
|
if marker := q.Get("marker"); marker != "" {
|
|
idx, err := strconv.Atoi(strings.TrimPrefix(marker, "azmarker-"))
|
|
if !strings.HasPrefix(marker, "azmarker-") || err != nil {
|
|
w.Header().Set("x-ms-error-code", "InvalidQueryParameterValue")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
start = idx
|
|
}
|
|
|
|
var matched []string
|
|
for _, key := range keys {
|
|
if strings.HasPrefix(key, prefix) {
|
|
matched = append(matched, key)
|
|
}
|
|
}
|
|
|
|
end := min(start+pageSize, len(matched))
|
|
var nextMarker string
|
|
if end < len(matched) {
|
|
nextMarker = fmt.Sprintf("azmarker-%d", end)
|
|
}
|
|
|
|
var body strings.Builder
|
|
body.WriteString(`<?xml version="1.0" encoding="utf-8"?><EnumerationResults><Blobs>`)
|
|
for _, key := range matched[start:end] {
|
|
fmt.Fprintf(&body, `<Blob><Name>%s</Name><Properties>`+
|
|
`<Last-Modified>Mon, 02 Jan 2006 15:04:05 GMT</Last-Modified>`+
|
|
`<Etag>0x8DEADBEEF</Etag><Content-Length>7</Content-Length>`+
|
|
`</Properties></Blob>`, key)
|
|
}
|
|
fmt.Fprintf(&body, `</Blobs><NextMarker>%s</NextMarker></EnumerationResults>`, nextMarker)
|
|
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
//nolint:errcheck
|
|
w.Write([]byte(body.String()))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
client, err := container.NewClientWithNoCredential(srv.URL+"/testbucket", nil)
|
|
if err != nil {
|
|
t.Fatalf("init container client: %v", err)
|
|
}
|
|
|
|
return client, state
|
|
}
|