mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-11 17:10:40 +02:00
The Lance namespace gateway took the request-body location field, trimmed a trailing slash, and passed it straight to the marker sink. That location feeds TableDataDirFromMetadataLocation, which joins it under /buckets and collapses any ../ segments, and writeMarker's CreateEntry then auto-creates every missing parent. A caller could point the location at another tenant's bucket, or escape /buckets entirely, and plant a fixed-name marker (recursively creating the parents) or hide a victim's live table with .lance-deregistered. Confine the declared location the way the Iceberg gateway already does: require an s3:// URI whose bucket is the caller's own and whose path carries no traversal segment, on both the declare and register handlers.
418 lines
17 KiB
Go
418 lines
17 KiB
Go
package lance
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest"
|
|
)
|
|
|
|
// openAuthenticator stands in for a gateway with no IAM configured, which is the
|
|
// mode the namespace falls open in.
|
|
type openAuthenticator struct{}
|
|
|
|
func (openAuthenticator) AuthenticateRequest(*http.Request) (string, interface{}, s3err.ErrorCode) {
|
|
return s3tables.DefaultAccountID, nil, s3err.ErrNone
|
|
}
|
|
|
|
func (openAuthenticator) DefaultAllow() bool { return true }
|
|
|
|
type testHarness struct {
|
|
router *mux.Router
|
|
filer *s3tablestest.MemFiler
|
|
admin *s3tables.Manager
|
|
server *Server
|
|
}
|
|
|
|
func newTestHarness(t *testing.T) *testHarness {
|
|
t.Helper()
|
|
filer := s3tablestest.Start(t)
|
|
server := NewServer(s3tables.NewManagerClient(filer.Client), openAuthenticator{})
|
|
server.SetS3Endpoint("http://127.0.0.1:8333")
|
|
server.SetS3Region(s3tables.DefaultRegion)
|
|
router := mux.NewRouter().SkipClean(true)
|
|
server.RegisterRoutes(router)
|
|
|
|
// Table buckets are created by an operator, not by a namespace client, so
|
|
// the harness seeds them the way the shell and admin console would.
|
|
admin := s3tables.NewManager()
|
|
admin.SetTrusted(true)
|
|
return &testHarness{router: router, filer: filer, admin: admin, server: server}
|
|
}
|
|
|
|
// createBucket seeds a table bucket the Lance namespace can then be pointed at.
|
|
// An empty format strips the declaration afterwards, which is the only way to
|
|
// get the shape a bucket made before formats existed has: one that still
|
|
// accepts either format.
|
|
func (h *testHarness) createBucket(t *testing.T, name, format string) {
|
|
t.Helper()
|
|
var resp s3tables.CreateTableBucketResponse
|
|
declared := format
|
|
if declared == "" {
|
|
declared = s3tables.FormatIceberg
|
|
}
|
|
err := h.admin.Execute(t.Context(), s3tables.NewManagerClient(h.filer.Client), "CreateTableBucket",
|
|
&s3tables.CreateTableBucketRequest{Name: name, Format: declared}, &resp, "")
|
|
if err != nil {
|
|
t.Fatalf("create table bucket %s: %v", name, err)
|
|
}
|
|
if format == "" {
|
|
h.undeclareBucket(t, name)
|
|
}
|
|
}
|
|
|
|
// undeclareBucket removes a bucket's format from its metadata, ageing it back to
|
|
// what the filer holds for a bucket created before the field existed.
|
|
func (h *testHarness) undeclareBucket(t *testing.T, name string) {
|
|
t.Helper()
|
|
entry := h.filer.Get(s3tables.TablesPath, name)
|
|
if entry == nil {
|
|
t.Fatalf("table bucket %s is not in the filer", name)
|
|
}
|
|
var metadata map[string]any
|
|
if err := json.Unmarshal(entry.Extended[s3tables.ExtendedKeyMetadata], &metadata); err != nil {
|
|
t.Fatalf("read bucket metadata: %v", err)
|
|
}
|
|
delete(metadata, "format")
|
|
updated, err := json.Marshal(metadata)
|
|
if err != nil {
|
|
t.Fatalf("write bucket metadata: %v", err)
|
|
}
|
|
extended := map[string][]byte{}
|
|
for key, value := range entry.Extended {
|
|
extended[key] = value
|
|
}
|
|
extended[s3tables.ExtendedKeyMetadata] = updated
|
|
h.filer.Put(s3tables.TablesPath, name, extended)
|
|
}
|
|
|
|
func (h *testHarness) bucketARN(t *testing.T, name string) string {
|
|
t.Helper()
|
|
arn, err := s3tables.BuildBucketARN(s3tables.DefaultRegion, s3tables.DefaultAccountID, name)
|
|
if err != nil {
|
|
t.Fatalf("build arn: %v", err)
|
|
}
|
|
return arn
|
|
}
|
|
|
|
func (h *testHarness) do(t *testing.T, method, target, body string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var reader *strings.Reader
|
|
if body == "" {
|
|
reader = strings.NewReader("")
|
|
} else {
|
|
reader = strings.NewReader(body)
|
|
}
|
|
req := httptest.NewRequest(method, target, reader)
|
|
recorder := httptest.NewRecorder()
|
|
h.router.ServeHTTP(recorder, req)
|
|
return recorder
|
|
}
|
|
|
|
func (h *testHarness) mustDo(t *testing.T, method, target, body string, want int) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
recorder := h.do(t, method, target, body)
|
|
if recorder.Code != want {
|
|
t.Fatalf("%s %s = %d (%s), want %d", method, target, recorder.Code, recorder.Body.String(), want)
|
|
}
|
|
return recorder
|
|
}
|
|
|
|
func decode[T any](t *testing.T, recorder *httptest.ResponseRecorder) T {
|
|
t.Helper()
|
|
var out T
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("decode %s: %v", recorder.Body.String(), err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// The lifecycle a Lance client drives: create the namespace, declare the table
|
|
// before any data exists, resolve it to a location, then deregister and bring it
|
|
// back by registering the same location.
|
|
func TestTableLifecycle(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
|
|
h.createBucket(t, "analytics", s3tables.FormatLance)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK)
|
|
|
|
declared := decode[DeclareTableResponse](t,
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/declare", `{}`, http.StatusOK))
|
|
if declared.Location != "s3://analytics/sales/orders" {
|
|
t.Fatalf("declared location = %q", declared.Location)
|
|
}
|
|
// The endpoint is plaintext, so a client that does not get allow_http fails
|
|
// with what looks like a credential error.
|
|
if declared.StorageOptions["aws_endpoint"] != "http://127.0.0.1:8333" ||
|
|
declared.StorageOptions["allow_http"] != "true" {
|
|
t.Fatalf("storage options = %v", declared.StorageOptions)
|
|
}
|
|
|
|
described := decode[DescribeTableResponse](t,
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/describe?check_declared=true", `{}`, http.StatusOK))
|
|
if described.Location != declared.Location {
|
|
t.Fatalf("described location = %q, want %q", described.Location, declared.Location)
|
|
}
|
|
if described.IsOnlyDeclared == nil || !*described.IsOnlyDeclared {
|
|
t.Fatalf("is_only_declared = %v, want true for a table with no data", described.IsOnlyDeclared)
|
|
}
|
|
|
|
listed := decode[ListTablesResponse](t,
|
|
h.mustDo(t, http.MethodGet, "/v1/namespace/analytics$sales/table/list", "", http.StatusOK))
|
|
if len(listed.Tables) != 1 || listed.Tables[0] != "analytics$sales$orders" {
|
|
t.Fatalf("listed tables = %v, want the full identifier", listed.Tables)
|
|
}
|
|
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/exists", `{}`, http.StatusOK)
|
|
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/deregister", `{}`, http.StatusOK)
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/exists", `{}`, http.StatusNotFound)
|
|
afterDrop := decode[ListTablesResponse](t,
|
|
h.mustDo(t, http.MethodGet, "/v1/namespace/analytics$sales/table/list", "", http.StatusOK))
|
|
if len(afterDrop.Tables) != 0 {
|
|
t.Fatalf("deregistered table still listed: %v", afterDrop.Tables)
|
|
}
|
|
// Deregistering preserves the data. The catalog entry is the dataset
|
|
// directory, so dropping it would take the dataset with it.
|
|
if h.filer.Get(s3tables.GetNamespacePath("analytics", "sales"), "orders") == nil {
|
|
t.Fatal("deregister deleted the dataset directory")
|
|
}
|
|
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/register",
|
|
`{"location":"s3://analytics/sales/orders"}`, http.StatusOK)
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/exists", `{}`, http.StatusOK)
|
|
|
|
// Dropping is the operation that does remove the data.
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/drop", `{}`, http.StatusOK)
|
|
if h.filer.Get(s3tables.GetNamespacePath("analytics", "sales"), "orders") != nil {
|
|
t.Fatal("drop left the dataset directory behind")
|
|
}
|
|
}
|
|
|
|
// Repointing a registered name at another dataset must not take the dataset it
|
|
// used to name with it.
|
|
func TestRegisterOverwriteKeepsTheOldDataset(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
h.createBucket(t, "analytics", s3tables.FormatLance)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK)
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/declare", `{}`, http.StatusOK)
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$archive/declare", `{}`, http.StatusOK)
|
|
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/register",
|
|
`{"location":"s3://analytics/sales/archive","mode":"Overwrite"}`, http.StatusOK)
|
|
|
|
described := decode[DescribeTableResponse](t,
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/describe", `{}`, http.StatusOK))
|
|
if described.Location != "s3://analytics/sales/archive" {
|
|
t.Fatalf("location after repointing = %q", described.Location)
|
|
}
|
|
if h.filer.Get(s3tables.GetNamespacePath("analytics", "sales"), "orders") == nil {
|
|
t.Fatal("repointing deleted the dataset the name used to hold")
|
|
}
|
|
}
|
|
|
|
// A required list field answers empty rather than null, which a generated
|
|
// client may decode differently.
|
|
func TestListAllTablesIsNeverNull(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
h.createBucket(t, "analytics", s3tables.FormatLance)
|
|
|
|
body := h.mustDo(t, http.MethodGet, "/v1/table", "", http.StatusOK).Body.String()
|
|
if strings.Contains(body, `"tables":null`) {
|
|
t.Fatalf("ListAllTables returned null for a required field: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestNamespaceListing(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
h.createBucket(t, "analytics", s3tables.FormatLance)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$finance/create", `{}`, http.StatusOK)
|
|
|
|
roots := decode[ListNamespacesResponse](t,
|
|
h.mustDo(t, http.MethodGet, "/v1/namespace/$/list", "", http.StatusOK))
|
|
if len(roots.Namespaces) != 1 || roots.Namespaces[0] != "analytics" {
|
|
t.Fatalf("root listing = %v, want the table buckets", roots.Namespaces)
|
|
}
|
|
|
|
children := decode[ListNamespacesResponse](t,
|
|
h.mustDo(t, http.MethodGet, "/v1/namespace/analytics/list", "", http.StatusOK))
|
|
if len(children.Namespaces) != 2 {
|
|
t.Fatalf("bucket listing = %v, want two namespaces", children.Namespaces)
|
|
}
|
|
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/exists", `{}`, http.StatusOK)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$nope/exists", `{}`, http.StatusNotFound)
|
|
|
|
// Fail mode reports a missing namespace as 400 on this operation, which is
|
|
// what the spec asks for; Skip reports success.
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$nope/drop", `{}`, http.StatusBadRequest)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$nope/drop", `{"mode":"Skip"}`, http.StatusNoContent)
|
|
}
|
|
|
|
// Storage flattens a namespace's parts, so creating a$b without a would leave
|
|
// an intermediate that listing derives from the name and describe denies
|
|
// exists. The spec asks for NamespaceNotFound, which keeps them consistent.
|
|
func TestCreateNamespaceRequiresItsParent(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
h.createBucket(t, "analytics", s3tables.FormatLance)
|
|
|
|
recorder := h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$missing$child/create", `{}`, http.StatusNotFound)
|
|
if got := decode[errorResponse](t, recorder); got.Code != codeNamespaceNotFound {
|
|
t.Fatalf("error code = %d, want %d", got.Code, codeNamespaceNotFound)
|
|
}
|
|
|
|
// With the parent in place the child is fine, and both are then listed and
|
|
// describable.
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$parent/create", `{}`, http.StatusOK)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$parent$child/create", `{}`, http.StatusOK)
|
|
|
|
listed := decode[ListNamespacesResponse](t,
|
|
h.mustDo(t, http.MethodGet, "/v1/namespace/analytics/list", "", http.StatusOK))
|
|
for _, name := range listed.Namespaces {
|
|
path := "/v1/namespace/analytics$" + name
|
|
h.mustDo(t, http.MethodPost, path+"/exists", `{}`, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
func TestCreateNamespaceModes(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
h.createBucket(t, "analytics", s3tables.FormatLance)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK)
|
|
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusConflict)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{"mode":"ExistOk"}`, http.StatusOK)
|
|
// snake_case is the other spelling the spec accepts.
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{"mode":"exist_ok"}`, http.StatusOK)
|
|
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/$/create", `{}`, http.StatusBadRequest)
|
|
}
|
|
|
|
// A Lance client must never resolve an Iceberg table's location, or it would
|
|
// write a dataset over a table another engine owns.
|
|
func TestIcebergTablesAreInvisible(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
// Undeclared, because a bucket that declares one format cannot hold the
|
|
// other - and mixing is exactly what this test needs to prove is hidden.
|
|
h.createBucket(t, "analytics", "")
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK)
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$vectors/declare", `{}`, http.StatusOK)
|
|
|
|
manager := h.admin
|
|
var created s3tables.CreateTableResponse
|
|
err := manager.Execute(t.Context(), s3tables.NewManagerClient(h.filer.Client), "CreateTable", &s3tables.CreateTableRequest{
|
|
TableBucketARN: h.bucketARN(t, "analytics"),
|
|
Namespace: []string{"sales"},
|
|
Name: "ledger",
|
|
Format: s3tables.FormatIceberg,
|
|
MetadataLocation: "s3://analytics/sales/ledger/metadata/v1.metadata.json",
|
|
}, &created, "")
|
|
if err != nil {
|
|
t.Fatalf("create iceberg table: %v", err)
|
|
}
|
|
|
|
listed := decode[ListTablesResponse](t,
|
|
h.mustDo(t, http.MethodGet, "/v1/namespace/analytics$sales/table/list", "", http.StatusOK))
|
|
if len(listed.Tables) != 1 || listed.Tables[0] != "analytics$sales$vectors" {
|
|
t.Fatalf("listing = %v, want only the lance table", listed.Tables)
|
|
}
|
|
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$ledger/describe", `{}`, http.StatusNotFound)
|
|
h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$ledger/exists", `{}`, http.StatusNotFound)
|
|
|
|
// Declaring over the Iceberg table must not quietly succeed and hand the
|
|
// Lance client a directory another format owns.
|
|
recorder := h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$ledger/declare", `{}`, http.StatusConflict)
|
|
if got := decode[errorResponse](t, recorder); got.Code != codeTableAlreadyExists {
|
|
t.Fatalf("error code = %d, want %d", got.Code, codeTableAlreadyExists)
|
|
}
|
|
}
|
|
|
|
// The route and the body naming different objects is a bad request, not a silent
|
|
// preference for one of them.
|
|
func TestRouteAndBodyMustAgree(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
h.createBucket(t, "analytics", s3tables.FormatLance)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK)
|
|
|
|
recorder := h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/declare",
|
|
`{"id":["analytics","sales","other"]}`, http.StatusBadRequest)
|
|
if got := decode[errorResponse](t, recorder); got.Code != codeInvalidInput {
|
|
t.Fatalf("error code = %d, want %d", got.Code, codeInvalidInput)
|
|
}
|
|
}
|
|
|
|
// A client-supplied location must resolve inside its own bucket. Without this,
|
|
// a "../"-laden or cross-bucket location escapes when the marker path is joined
|
|
// under /buckets, letting the request reach another tenant's namespace, or
|
|
// outside /buckets entirely, and auto-create the parent directories on the way.
|
|
func TestLocationOutsideBucketIsRejected(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
location string
|
|
}{
|
|
{"another bucket", "s3://victim/secret"},
|
|
{"traversal into another bucket", "s3://analytics/../victim/secret"},
|
|
{"traversal above the buckets root", "s3://analytics/../../etc/cron.d"},
|
|
{"not an s3 uri", "../../etc/cron.d"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
dir := s3tables.TableDataDirFromMetadataLocation(tc.location)
|
|
body := `{"location":"` + tc.location + `"}`
|
|
|
|
// declare writes .lance-reserved at the location: rejection must leave
|
|
// nothing behind at the escaped directory.
|
|
t.Run("declare", func(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
h.createBucket(t, "analytics", s3tables.FormatLance)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK)
|
|
|
|
recorder := h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/declare", body, http.StatusBadRequest)
|
|
if got := decode[errorResponse](t, recorder); got.Code != codeInvalidInput {
|
|
t.Fatalf("error code = %d, want %d", got.Code, codeInvalidInput)
|
|
}
|
|
if dir != "" && h.filer.Get(dir, reservedMarker) != nil {
|
|
t.Fatalf("declare wrote %s into %s despite rejection", reservedMarker, dir)
|
|
}
|
|
})
|
|
|
|
// register removes .lance-deregistered at the location: rejection must
|
|
// leave a victim's marker in place rather than un-hiding their table.
|
|
t.Run("register", func(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
h.createBucket(t, "analytics", s3tables.FormatLance)
|
|
h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK)
|
|
if dir != "" {
|
|
h.filer.PutFile(dir, deregisteredMarker, time.Unix(1, 0))
|
|
}
|
|
|
|
recorder := h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/register", body, http.StatusBadRequest)
|
|
if got := decode[errorResponse](t, recorder); got.Code != codeInvalidInput {
|
|
t.Fatalf("error code = %d, want %d", got.Code, codeInvalidInput)
|
|
}
|
|
if dir != "" && h.filer.Get(dir, deregisteredMarker) == nil {
|
|
t.Fatalf("register removed %s from %s despite rejection", deregisteredMarker, dir)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
// The data plane needs Lance format support that does not exist in Go, so it
|
|
// answers with the spec's own Unsupported code rather than a bare 404.
|
|
func TestDataPlaneIsUnsupported(t *testing.T) {
|
|
h := newTestHarness(t)
|
|
recorder := h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/query", `{}`, http.StatusNotImplemented)
|
|
if got := decode[errorResponse](t, recorder); got.Code != codeUnsupported {
|
|
t.Fatalf("error code = %d, want %d", got.Code, codeUnsupported)
|
|
}
|
|
}
|