Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled
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)
|
|
}
|
|
}
|