From 62c6787a17020b26f5851e6067b00f930ddb6557 Mon Sep 17 00:00:00 2001 From: Ben McClelland Date: Thu, 30 Jul 2026 10:31:09 -0700 Subject: [PATCH] feat: add new vgwrdma service for cuObject compatible server --- .github/workflows/vgwrdma-build.yml | 38 + .gitignore | 6 + Makefile | 110 ++ README.md | 6 + build/cuobjtest-gpu-builder/Dockerfile | 40 + build/cuobjtest-host-builder/Dockerfile | 21 + build/vgwrdma-builder/Dockerfile | 33 + cmd/cuobjtest/main.go | 519 +++++++++ cmd/cuobjtest/mode_gpu.go | 7 + cmd/cuobjtest/mode_host.go | 7 + cmd/{versitygw => internal/gwcli}/admin.go | 10 +- cmd/{versitygw => internal/gwcli}/azure.go | 10 +- cmd/internal/gwcli/gwcli.go | 46 + cmd/{versitygw => internal/gwcli}/plugin.go | 8 +- cmd/{versitygw => internal/gwcli}/posix.go | 12 +- cmd/{versitygw => internal/gwcli}/s3.go | 7 +- cmd/{versitygw => internal/gwcli}/scoutfs.go | 12 +- cmd/{versitygw => internal/gwcli}/signal.go | 16 +- cmd/{versitygw => internal/gwcli}/utils.go | 6 +- cmd/versitygw/gateway_test.go | 7 +- cmd/versitygw/main.go | 42 +- cmd/vgwrdma/main.go | 1064 ++++++++++++++++++ cubackend/cuserver.go | 359 ++++++ cubackend/cuserver_common.go | 36 + cubackend/cuserver_stub.go | 36 + cumiddleware/cuobj.go | 245 ++++ cumiddleware/cuobj_test.go | 182 +++ cuobjclient/limits.go | 19 + cuobjclient/session_host_linux.go | 163 +++ cuobjclient/session_linux.go | 210 ++++ cuobjclient/session_stub.go | 66 ++ cuobjclient/transport.go | 164 +++ cuwrapper/cuobjclient_wrapper.cpp | 153 +++ cuwrapper/cuobjclient_wrapper.h | 67 ++ cuwrapper/cuobjserver_wrapper.cpp | 247 ++++ cuwrapper/cuobjserver_wrapper.h | 117 ++ cuwrapper/rdma_host_client_wrapper.cpp | 460 ++++++++ cuwrapper/rdma_host_client_wrapper.h | 80 ++ rdma/bufferpool/pool.go | 98 ++ rdma/hostclient/client_linux.go | 143 +++ rdma/hostclient/client_stub.go | 48 + rdma/rdma.go | 298 +++++ rdma/rdma_common.go | 68 ++ rdma/rdma_stub.go | 86 ++ 44 files changed, 5315 insertions(+), 57 deletions(-) create mode 100644 .github/workflows/vgwrdma-build.yml create mode 100644 build/cuobjtest-gpu-builder/Dockerfile create mode 100644 build/cuobjtest-host-builder/Dockerfile create mode 100644 build/vgwrdma-builder/Dockerfile create mode 100644 cmd/cuobjtest/main.go create mode 100644 cmd/cuobjtest/mode_gpu.go create mode 100644 cmd/cuobjtest/mode_host.go rename cmd/{versitygw => internal/gwcli}/admin.go (99%) rename cmd/{versitygw => internal/gwcli}/azure.go (90%) create mode 100644 cmd/internal/gwcli/gwcli.go rename cmd/{versitygw => internal/gwcli}/plugin.go (91%) rename cmd/{versitygw => internal/gwcli}/posix.go (96%) rename cmd/{versitygw => internal/gwcli}/s3.go (96%) rename cmd/{versitygw => internal/gwcli}/scoutfs.go (94%) rename cmd/{versitygw => internal/gwcli}/signal.go (68%) rename cmd/{versitygw => internal/gwcli}/utils.go (97%) create mode 100644 cmd/vgwrdma/main.go create mode 100644 cubackend/cuserver.go create mode 100644 cubackend/cuserver_common.go create mode 100644 cubackend/cuserver_stub.go create mode 100644 cumiddleware/cuobj.go create mode 100644 cumiddleware/cuobj_test.go create mode 100644 cuobjclient/limits.go create mode 100644 cuobjclient/session_host_linux.go create mode 100644 cuobjclient/session_linux.go create mode 100644 cuobjclient/session_stub.go create mode 100644 cuobjclient/transport.go create mode 100644 cuwrapper/cuobjclient_wrapper.cpp create mode 100644 cuwrapper/cuobjclient_wrapper.h create mode 100644 cuwrapper/cuobjserver_wrapper.cpp create mode 100644 cuwrapper/cuobjserver_wrapper.h create mode 100644 cuwrapper/rdma_host_client_wrapper.cpp create mode 100644 cuwrapper/rdma_host_client_wrapper.h create mode 100644 rdma/bufferpool/pool.go create mode 100644 rdma/hostclient/client_linux.go create mode 100644 rdma/hostclient/client_stub.go create mode 100644 rdma/rdma.go create mode 100644 rdma/rdma_common.go create mode 100644 rdma/rdma_stub.go diff --git a/.github/workflows/vgwrdma-build.yml b/.github/workflows/vgwrdma-build.yml new file mode 100644 index 00000000..277e8285 --- /dev/null +++ b/.github/workflows/vgwrdma-build.yml @@ -0,0 +1,38 @@ +name: vgwrdma build +permissions: {} +on: pull_request + +jobs: + + vgwrdma: + name: Build vgwrdma (linux/amd64, cgo) + runs-on: ubuntu-latest + steps: + + - name: Check out code + uses: actions/checkout@v7 + + - name: make vgwrdma-docker + run: make vgwrdma-docker + + cuobjtest-gpu: + name: Build cuobjtest-gpu (linux/amd64, cgo) + runs-on: ubuntu-latest + steps: + + - name: Check out code + uses: actions/checkout@v7 + + - name: make cuobjtest-gpu-docker + run: make cuobjtest-gpu-docker + + cuobjtest-host: + name: Build cuobjtest-host (linux/amd64, cgo) + runs-on: ubuntu-latest + steps: + + - name: Check out code + uses: actions/checkout@v7 + + - name: make cuobjtest-host-docker + run: make cuobjtest-host-docker diff --git a/.gitignore b/.gitignore index 82993c5a..3b411ecf 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,12 @@ .DS_Store cmd/versitygw/versitygw /versitygw +/vgwrdma +/cuobjtest +/.cache/ +/rdma/libcuobjwrapper.a +/rdma/libcuobjclientwrapper.a +/rdma/libhostclientwrapper.a # Test binary, built with `go test -c` *.test diff --git a/Makefile b/Makefile index 0bf43207..92e9740c 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,37 @@ DCCMD=docker-compose DOCKERCOMPOSE=$(DCCMD) -f tests/docker-compose.yml --env-file .env.dev --project-directory . BIN=versitygw +VGWRDMA_BIN=vgwrdma +VGWRDMA_CMD=./cmd/vgwrdma +VGWRDMA_BUILDER_DOCKERFILE ?= build/vgwrdma-builder/Dockerfile +VGWRDMA_BUILDER_IMAGE ?= vgwrdma-builder:local +VGWRDMA_BUILDER_PLATFORM ?= linux/amd64 +CUOBJTEST_BIN=cuobjtest +CUOBJTEST_CMD=./cmd/cuobjtest +CUOBJTEST_HOST_TAG ?= cuobjclient_host +CUOBJTEST_GPU_BUILDER_DOCKERFILE ?= build/cuobjtest-gpu-builder/Dockerfile +CUOBJTEST_GPU_BUILDER_IMAGE ?= cuobjtest-gpu-builder:local +CUOBJTEST_GPU_BUILDER_PLATFORM ?= linux/amd64 +CUOBJTEST_HOST_BUILDER_DOCKERFILE ?= build/cuobjtest-host-builder/Dockerfile +CUOBJTEST_HOST_BUILDER_IMAGE ?= cuobjtest-host-builder:local +CUOBJTEST_HOST_BUILDER_PLATFORM ?= linux/amd64 + +# RDMA build dependencies (Linux/amd64 + cgo) +CUOBJ_LIB_DIR ?= /usr/local/cuda-13.3/targets/x86_64-linux/lib +CUOBJ_SERVER_INC_DIR ?= /usr/include +CUOBJ_CLIENT_INC_DIR ?= /usr/include +CUOBJ_CUDA_INC_DIR ?= /usr/local/cuda/include +CXX ?= g++ +AR ?= ar + +# Static archive for the cuObjServer C++ wrapper used by the RDMA gateway. +VGWRDMA_WRAPPER_LIB=rdma/libcuobjwrapper.a +VGWRDMA_RDMA_CGO_CFLAGS=-I$(CUOBJ_SERVER_INC_DIR) +VGWRDMA_RDMA_CGO_LDFLAGS=-L$(CUOBJ_LIB_DIR) -Wl,-rpath,$(CUOBJ_LIB_DIR) +CUOBJCLIENT_WRAPPER_LIB=rdma/libcuobjclientwrapper.a +CUOBJCLIENT_CGO_CFLAGS=-I$(CUOBJ_CLIENT_INC_DIR) -I$(CUOBJ_CUDA_INC_DIR) +CUOBJCLIENT_CGO_LDFLAGS=-L$(CUOBJ_LIB_DIR) -Wl,-rpath,$(CUOBJ_LIB_DIR) +HOSTCLIENT_WRAPPER_LIB=rdma/libhostclientwrapper.a VERSION := $(shell if test -e VERSION; then cat VERSION; else git describe --abbrev=0 --tags HEAD; fi) BUILD := $(shell git rev-parse --short HEAD || echo release-rpm) @@ -38,6 +69,80 @@ build: $(BIN) $(BIN): $(GOBUILD) $(LDFLAGS) -o $(BIN) cmd/$(BIN)/*.go +$(VGWRDMA_WRAPPER_LIB): cuwrapper/cuobjserver_wrapper.cpp cuwrapper/cuobjserver_wrapper.h + $(CXX) -c -fPIC \ + -I$(CUOBJ_SERVER_INC_DIR) -Icuwrapper \ + -o cuwrapper/cuobjserver_wrapper.o \ + cuwrapper/cuobjserver_wrapper.cpp + $(AR) rcs $(VGWRDMA_WRAPPER_LIB) cuwrapper/cuobjserver_wrapper.o + rm -f cuwrapper/cuobjserver_wrapper.o + +$(CUOBJCLIENT_WRAPPER_LIB): cuwrapper/cuobjclient_wrapper.cpp cuwrapper/cuobjclient_wrapper.h + $(CXX) -c -fPIC \ + -I$(CUOBJ_CLIENT_INC_DIR) -I$(CUOBJ_CUDA_INC_DIR) -Icuwrapper \ + -o cuwrapper/cuobjclient_wrapper.o \ + cuwrapper/cuobjclient_wrapper.cpp + $(AR) rcs $(CUOBJCLIENT_WRAPPER_LIB) cuwrapper/cuobjclient_wrapper.o + rm -f cuwrapper/cuobjclient_wrapper.o + +$(HOSTCLIENT_WRAPPER_LIB): cuwrapper/rdma_host_client_wrapper.cpp cuwrapper/rdma_host_client_wrapper.h + $(CXX) -c -fPIC \ + -Icuwrapper \ + -o cuwrapper/rdma_host_client_wrapper.o \ + cuwrapper/rdma_host_client_wrapper.cpp + $(AR) rcs $(HOSTCLIENT_WRAPPER_LIB) cuwrapper/rdma_host_client_wrapper.o + rm -f cuwrapper/rdma_host_client_wrapper.o + +.PHONY: vgwrdma +vgwrdma: $(VGWRDMA_WRAPPER_LIB) + CGO_ENABLED=1 \ + CGO_CFLAGS="$(VGWRDMA_RDMA_CGO_CFLAGS)" \ + CGO_LDFLAGS="$(VGWRDMA_RDMA_CGO_LDFLAGS)" \ + $(GOBUILD) -buildvcs=false $(LDFLAGS) -o $(VGWRDMA_BIN) $(VGWRDMA_CMD) + +.PHONY: cuobjtest +cuobjtest: cuobjtest-gpu + +.PHONY: cuobjtest-gpu +cuobjtest-gpu: $(CUOBJCLIENT_WRAPPER_LIB) + CGO_ENABLED=1 \ + CGO_CFLAGS="$(CUOBJCLIENT_CGO_CFLAGS)" \ + CGO_LDFLAGS="$(CUOBJCLIENT_CGO_LDFLAGS)" \ + $(GOBUILD) -buildvcs=false $(LDFLAGS) -o $(CUOBJTEST_BIN) $(CUOBJTEST_CMD) + +.PHONY: cuobjtest-host +cuobjtest-host: $(HOSTCLIENT_WRAPPER_LIB) + CGO_ENABLED=1 \ + $(GOBUILD) -buildvcs=false -tags $(CUOBJTEST_HOST_TAG) $(LDFLAGS) -o $(CUOBJTEST_BIN) $(CUOBJTEST_CMD) + +.PHONY: vgwrdma-builder-image +vgwrdma-builder-image: + docker build --platform $(VGWRDMA_BUILDER_PLATFORM) -f $(VGWRDMA_BUILDER_DOCKERFILE) -t $(VGWRDMA_BUILDER_IMAGE) . + +.PHONY: vgwrdma-docker +vgwrdma-docker: vgwrdma-builder-image + docker run --rm --platform $(VGWRDMA_BUILDER_PLATFORM) -v "$(CURDIR)":/workspace -w /workspace $(VGWRDMA_BUILDER_IMAGE) + +.PHONY: cuobjtest-gpu-builder-image +cuobjtest-gpu-builder-image: + docker build --platform $(CUOBJTEST_GPU_BUILDER_PLATFORM) -f $(CUOBJTEST_GPU_BUILDER_DOCKERFILE) -t $(CUOBJTEST_GPU_BUILDER_IMAGE) . + +.PHONY: cuobjtest-gpu-docker +cuobjtest-gpu-docker: cuobjtest-gpu-builder-image + docker run --rm --platform $(CUOBJTEST_GPU_BUILDER_PLATFORM) \ + -v "$(CURDIR)":/workspace \ + -w /workspace $(CUOBJTEST_GPU_BUILDER_IMAGE) + +.PHONY: cuobjtest-host-builder-image +cuobjtest-host-builder-image: + docker build --platform $(CUOBJTEST_HOST_BUILDER_PLATFORM) -f $(CUOBJTEST_HOST_BUILDER_DOCKERFILE) -t $(CUOBJTEST_HOST_BUILDER_IMAGE) . + +.PHONY: cuobjtest-host-docker +cuobjtest-host-docker: cuobjtest-host-builder-image + docker run --rm --platform $(CUOBJTEST_HOST_BUILDER_PLATFORM) \ + -v "$(CURDIR)":/workspace \ + -w /workspace $(CUOBJTEST_HOST_BUILDER_IMAGE) + testbin: $(GOBUILD) $(LDFLAGS) -o $(BIN) -cover -race cmd/$(BIN)/*.go @@ -61,6 +166,11 @@ clean: .PHONY: cleanall cleanall: clean rm -f $(BIN) + rm -f $(VGWRDMA_BIN) + rm -f $(CUOBJTEST_BIN) + rm -f $(VGWRDMA_WRAPPER_LIB) + rm -f $(CUOBJCLIENT_WRAPPER_LIB) + rm -f $(HOSTCLIENT_WRAPPER_LIB) rm -f versitygw-*.tar rm -f versitygw-*.tar.gz diff --git a/README.md b/README.md index 7ae04d4f..580017f8 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,12 @@ Get more details about the new (optional) WebGUI management/explorer here: [http ![admin-explorer](https://github.com/user-attachments/assets/e99db171-2c72-4d0f-8c8d-480a56e1c8a1) +### S3 RDMA +VersityGW supports S3 over RDMA (Remote Direct Memory Access), enabling high-throughput, low-latency object transfers that bypass the kernel network stack. This is particularly useful for HPC and data-intensive workloads where network overhead is a bottleneck. +See the [S3 RDMA](https://github.com/versity/versitygw/wiki/S3-RDMA) wiki page for setup and usage details. + +`vgwrdma` is a VersityGW-based service that exposes a standard S3 API accelerated with NVIDIA's cuObject (GPUDirect Storage for Objects) protocol. + ### Static Website Hosting Serve S3 buckets as static websites with index documents, custom error pages, and routing rules. Enable a separate website endpoint with `--website :8090 --website-domain example.com` for virtual-host style routing (`blog.example.com` serves bucket `blog`, `example.com` serves bucket `example.com`). diff --git a/build/cuobjtest-gpu-builder/Dockerfile b/build/cuobjtest-gpu-builder/Dockerfile new file mode 100644 index 00000000..2776b4b4 --- /dev/null +++ b/build/cuobjtest-gpu-builder/Dockerfile @@ -0,0 +1,40 @@ +# syntax=docker/dockerfile:1.7 + +ARG CUDA_TAG=13.3.0-devel-rockylinux9 +FROM --platform=linux/amd64 nvidia/cuda:${CUDA_TAG} + +ARG CUDA_TAG +ARG CUOBJCLIENT_PKG_SUFFIX= + +# Install build prerequisites and cuObject client development packages. +RUN set -eux; \ + cuda_version="${CUDA_TAG%%-*}"; \ + cuda_suffix="$(printf '%s' "$cuda_version" | awk -F. '{print $1"-"$2}')"; \ + pkg_suffix="${CUOBJCLIENT_PKG_SUFFIX:-$cuda_suffix}"; \ + dnf -y install 'dnf-command(config-manager)'; \ + dnf config-manager --add-repo https://developer.download.nvidia.com/compute/cuda/repos/rhel9/x86_64/cuda-rhel9.repo; \ + dnf -y makecache; \ + dnf -y install \ + libcuobjclient-${pkg_suffix} \ + libcuobjclient-devel-${pkg_suffix} \ + rdma-core-devel \ + numactl-libs \ + gcc \ + gcc-c++ \ + make \ + golang \ + git \ + findutils \ + which; \ + dnf clean all + +WORKDIR /workspace + +# Match current package install layout discovered in the NVIDIA Rocky image. +ENV CUOBJ_LIB_DIR=/usr/local/cuda/targets/x86_64-linux/lib +ENV CUOBJ_CLIENT_INC_DIR=/usr/include +ENV CUOBJ_CUDA_INC_DIR=/usr/local/cuda/include +ENV CUOBJCLIENT_CGO_LDFLAGS="-L/usr/local/cuda/targets/x86_64-linux/lib -Wl,-rpath,/usr/local/cuda/targets/x86_64-linux/lib -Wl,-rpath-link,/usr/local/cuda/targets/x86_64-linux/lib" + +# Default command builds GPU-mode cuobjtest in a mounted workspace checkout. +CMD ["bash", "-lc", "make cuobjtest-gpu CUOBJ_LIB_DIR=$CUOBJ_LIB_DIR CUOBJ_CLIENT_INC_DIR=$CUOBJ_CLIENT_INC_DIR CUOBJ_CUDA_INC_DIR=$CUOBJ_CUDA_INC_DIR CUOBJCLIENT_CGO_LDFLAGS=\"$CUOBJCLIENT_CGO_LDFLAGS\""] diff --git a/build/cuobjtest-host-builder/Dockerfile b/build/cuobjtest-host-builder/Dockerfile new file mode 100644 index 00000000..610fdf83 --- /dev/null +++ b/build/cuobjtest-host-builder/Dockerfile @@ -0,0 +1,21 @@ +# syntax=docker/dockerfile:1.7 + +FROM --platform=linux/amd64 rockylinux:9 + +# Install build prerequisites for host-memory RDMA cuobjtest mode. +RUN set -eux; \ + dnf -y install \ + rdma-core-devel \ + gcc \ + gcc-c++ \ + make \ + golang \ + git \ + findutils \ + which; \ + dnf clean all + +WORKDIR /workspace + +# Default command builds host-mode cuobjtest in a mounted workspace checkout. +CMD ["bash", "-lc", "make cuobjtest-host"] diff --git a/build/vgwrdma-builder/Dockerfile b/build/vgwrdma-builder/Dockerfile new file mode 100644 index 00000000..3857afdd --- /dev/null +++ b/build/vgwrdma-builder/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1.7 + +ARG CUDA_TAG=13.3.0-devel-rockylinux9 +FROM --platform=linux/amd64 nvidia/cuda:${CUDA_TAG} + +# Install build prerequisites and cuObject server development packages. +RUN set -eux; \ + dnf -y install 'dnf-command(config-manager)'; \ + dnf config-manager --add-repo https://developer.download.nvidia.com/compute/cuda/repos/rhel9/x86_64/cuda-rhel9.repo; \ + dnf -y makecache; \ + dnf -y install \ + cuobjserver \ + libcuobjserver-devel \ + rdma-core-devel \ + numactl-libs \ + gcc \ + gcc-c++ \ + make \ + golang \ + git \ + findutils \ + which; \ + dnf clean all + +WORKDIR /workspace + +# Match current package install layout discovered in the NVIDIA Rocky image. +ENV CUOBJ_LIB_DIR=/usr/lib64 +ENV CUOBJ_SERVER_INC_DIR=/usr/include +ENV VGWRDMA_RDMA_CGO_LDFLAGS="-L/usr/lib64 -Wl,-rpath,/usr/lib64 -Wl,-rpath-link,/usr/lib64" + +# Default command builds vgwrdma in a mounted workspace checkout. +CMD ["bash", "-lc", "make vgwrdma CUOBJ_LIB_DIR=$CUOBJ_LIB_DIR CUOBJ_SERVER_INC_DIR=$CUOBJ_SERVER_INC_DIR VGWRDMA_RDMA_CGO_LDFLAGS=\"$VGWRDMA_RDMA_CGO_LDFLAGS\""] diff --git a/cmd/cuobjtest/main.go b/cmd/cuobjtest/main.go new file mode 100644 index 00000000..706801dc --- /dev/null +++ b/cmd/cuobjtest/main.go @@ -0,0 +1,519 @@ +// 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 main + +import ( + "bytes" + "context" + crand "crypto/rand" + "errors" + "flag" + "fmt" + "hash/crc32" + "io" + "math" + "os" + "sort" + "strconv" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + s3lib "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + + "github.com/versity/versitygw/cuobjclient" +) + +var ( + endpoint = flag.String("endpoint", "http://localhost:7070", "cuserver S3 endpoint URL") + bucket = flag.String("bucket", "cuobjtest", "S3 bucket (created if absent)") + key = flag.String("key", "cuobjtest-object", "S3 object key prefix") + sizeStr = flag.String("size", "64MiB", "Transfer size per iteration: e.g. 4MiB, 256MiB, 1GiB") + access = flag.String("access", "admin", "S3 access key") + secret = flag.String("secret", "", "S3 secret key (default: $AWS_SECRET_ACCESS_KEY); prefer the environment variable over this flag, which is visible in shell history and process listings") + region = flag.String("region", "us-east-1", "S3 region") + iterations = flag.Int("n", 3, "Number of benchmark iterations") + putOnly = flag.Bool("put-only", false, "Run PUT only (skip GET and checksum verification)") + getOnly = flag.Bool("get-only", false, "Run GET only (assumes object already exists)") + stdS3 = flag.Bool("std-s3", false, "Use standard S3 PUT/GET (no cuObject RDMA transfer)") +) + +func main() { + flag.Parse() + + if *secret == "" { + *secret = os.Getenv("AWS_SECRET_ACCESS_KEY") + } + if *secret == "" { + fmt.Fprintln(os.Stderr, "cuobjtest: -secret or AWS_SECRET_ACCESS_KEY is required") + flag.Usage() + os.Exit(1) + } + if *putOnly && *getOnly { + fatalf("cuobjtest: -put-only and -get-only are mutually exclusive") + } + if *iterations < 1 { + fatalf("cuobjtest: -n must be >= 1") + } + + size, err := parseSize(*sizeStr) + if err != nil { + fatalf("cuobjtest: -size: %v", err) + } + if size <= 0 || size > cuobjclient.MaxTransferSize { + fatalf("cuobjtest: -size must be between 1 B and %d bytes", cuobjclient.MaxTransferSize) + } + + if err := runBenchmark(size); err != nil { + fatalf("cuobjtest: %v", err) + } +} + +type iterationResult struct { + index int + putDur time.Duration + getDur time.Duration + status string + err error +} + +type benchmarkStats struct { + putDurs []time.Duration + getDurs []time.Duration + mismatch int +} + +func runBenchmark(size int) error { + base := newS3Client(*endpoint, *access, *secret, *region) + if err := ensureBucket(base, *bucket); err != nil { + return err + } + + printRunHeader(size) + + stats := benchmarkStats{} + var results []iterationResult + var benchmarkDur time.Duration + if *stdS3 { + results, benchmarkDur = runConcurrentStdS3(base, size) + } else { + results, benchmarkDur = runConcurrent(base, size) + } + sort.Slice(results, func(i, j int) bool { + return results[i].index < results[j].index + }) + for _, r := range results { + if r.err != nil { + return fmt.Errorf("iter %d: %w", r.index, r.err) + } + accumulateStats(&stats, r) + printIterationResult(size, r) + } + + return printSummary(stats, benchmarkDur, size) +} + +func printRunHeader(size int) { + mode := runModeLabel() + if *stdS3 { + mode = "standard-s3" + } + fmt.Printf("\ncuobjtest endpoint=%-28s size=%s iterations=%d\n", *endpoint, formatSize(size), *iterations) + fmt.Printf(" mode=%s\n\n", mode) + if *putOnly { + fmt.Printf("%-4s %-14s %-10s\n", "#", "PUT latency", "PUT GB/s") + } else if *getOnly { + fmt.Printf("%-4s %-14s %-10s\n", "#", "GET latency", "GET GB/s") + } else { + fmt.Printf("%-4s %-14s %-10s %-14s %-10s %s\n", + "#", "PUT latency", "PUT GB/s", "GET latency", "GET GB/s", "checksum") + } + fmt.Printf("%s\n", strings.Repeat("-", 70)) +} + +func accumulateStats(stats *benchmarkStats, r iterationResult) { + if !*getOnly { + stats.putDurs = append(stats.putDurs, r.putDur) + } + if !*putOnly { + stats.getDurs = append(stats.getDurs, r.getDur) + } + if r.status != "OK" { + stats.mismatch++ + } +} + +func printSummary(stats benchmarkStats, benchmarkDur time.Duration, size int) error { + + fmt.Printf("%s\n", strings.Repeat("-", 70)) + putCount := len(stats.putDurs) + getCount := len(stats.getDurs) + putBytes := int64(putCount * size) + getBytes := int64(getCount * size) + totalBytes := putBytes + getBytes + // PUT/GET throughput is measured against the time actually spent in + // PUT/GET calls, not the combined benchmarkDur (which also includes + // the other operation and buffer preparation). + putDur := sumDurs(stats.putDurs) + getDur := sumDurs(stats.getDurs) + + fmt.Printf("OVERALL elapsed=%s", fmtDur(benchmarkDur)) + if putCount > 0 { + fmt.Printf(" PUT agg=%s (%d ops, %.2f ops/s)", + fmt.Sprintf("%.3f GB/s", float64(putBytes)/putDur.Seconds()/1e9), + putCount, + float64(putCount)/putDur.Seconds()) + } + if getCount > 0 { + fmt.Printf(" GET agg=%s (%d ops, %.2f ops/s)", + fmt.Sprintf("%.3f GB/s", float64(getBytes)/getDur.Seconds()/1e9), + getCount, + float64(getCount)/getDur.Seconds()) + } + if putCount > 0 && getCount > 0 { + fmt.Printf(" COMBINED=%s", + fmt.Sprintf("%.3f GB/s", float64(totalBytes)/benchmarkDur.Seconds()/1e9)) + } + fmt.Printf("\n") + + if *iterations > 1 { + if !*getOnly { + pa, plo, phi := durStats(stats.putDurs) + fmt.Printf("PUT avg=%-10s min=%-10s max=%-10s avg %s\n", + fmtDur(pa), fmtDur(plo), fmtDur(phi), fmtGBps(size, pa)) + } + if !*putOnly { + ga, glo, ghi := durStats(stats.getDurs) + fmt.Printf("GET avg=%-10s min=%-10s max=%-10s avg %s\n", + fmtDur(ga), fmtDur(glo), fmtDur(ghi), fmtGBps(size, ga)) + } + } + + if *getOnly { + fmt.Printf("\nGET-only run complete.\n") + return nil + } + if *putOnly { + fmt.Printf("\nPUT-only run complete.\n") + return nil + } + if stats.mismatch > 0 { + return fmt.Errorf("%d/%d iterations had checksum mismatches", stats.mismatch, *iterations) + } + fmt.Printf("\nAll checksums OK.\n") + return nil +} + +func runConcurrent(base *s3lib.Client, size int) ([]iterationResult, time.Duration) { + session, err := cuobjclient.NewSession(size) + if err != nil { + return []iterationResult{{index: 1, err: fmt.Errorf("init cuObj session: %w", err)}}, 0 + } + defer session.Close() + + putHost := make([]byte, size) + getHost := make([]byte, size) + out := make([]iterationResult, 0, *iterations) + + benchmarkStart := time.Now() + for i := 1; i <= *iterations; i++ { + res := iterationResult{index: i, status: "OK"} + iterKey := *key + + if !*getOnly { + if _, err := crand.Read(putHost); err != nil { + res.err = fmt.Errorf("fill PUT buffer: %w", err) + out = append(out, res) + continue + } + putCRC := crc32.ChecksumIEEE(putHost) + + putStart := time.Now() + err := session.Upload(base, *bucket, iterKey, putHost) + res.putDur = time.Since(putStart) + if err != nil { + res.err = fmt.Errorf("PUT: %w", err) + out = append(out, res) + continue + } + + if *putOnly { + out = append(out, res) + continue + } + + getStart := time.Now() + err = session.Download(base, *bucket, iterKey, getHost) + res.getDur = time.Since(getStart) + if err != nil { + res.err = fmt.Errorf("GET: %w", err) + out = append(out, res) + continue + } + getCRC := crc32.ChecksumIEEE(getHost) + if getCRC != putCRC { + res.status = fmt.Sprintf("MISMATCH put=%08x get=%08x", putCRC, getCRC) + } + out = append(out, res) + continue + } + + getStart := time.Now() + err := session.Download(base, *bucket, iterKey, getHost) + res.getDur = time.Since(getStart) + if err != nil { + res.err = fmt.Errorf("GET: %w", err) + } + out = append(out, res) + } + + return out, time.Since(benchmarkStart) +} + +// runConcurrentStdS3 exercises plain S3 PUT/GET (request body carries the +// actual data, no RDMA headers) so it can be benchmarked against the +// cuObject RDMA path in runConcurrent. +func runConcurrentStdS3(base *s3lib.Client, size int) ([]iterationResult, time.Duration) { + putHost := make([]byte, size) + getHost := make([]byte, size) + out := make([]iterationResult, 0, *iterations) + + benchmarkStart := time.Now() + for i := 1; i <= *iterations; i++ { + res := iterationResult{index: i, status: "OK"} + iterKey := *key + + if !*getOnly { + if _, err := crand.Read(putHost); err != nil { + res.err = fmt.Errorf("fill PUT buffer: %w", err) + out = append(out, res) + continue + } + putCRC := crc32.ChecksumIEEE(putHost) + + putStart := time.Now() + err := s3PutObject(base, *bucket, iterKey, putHost) + res.putDur = time.Since(putStart) + if err != nil { + res.err = fmt.Errorf("PUT: %w", err) + out = append(out, res) + continue + } + + if *putOnly { + out = append(out, res) + continue + } + + getStart := time.Now() + err = s3GetObject(base, *bucket, iterKey, getHost) + res.getDur = time.Since(getStart) + if err != nil { + res.err = fmt.Errorf("GET: %w", err) + out = append(out, res) + continue + } + getCRC := crc32.ChecksumIEEE(getHost) + if getCRC != putCRC { + res.status = fmt.Sprintf("MISMATCH put=%08x get=%08x", putCRC, getCRC) + } + out = append(out, res) + continue + } + + getStart := time.Now() + err := s3GetObject(base, *bucket, iterKey, getHost) + res.getDur = time.Since(getStart) + if err != nil { + res.err = fmt.Errorf("GET: %w", err) + } + out = append(out, res) + } + + return out, time.Since(benchmarkStart) +} + +// s3PutObject issues a standard S3 PUT carrying data in the request body. +func s3PutObject(base *s3lib.Client, bucket, key string, data []byte) error { + _, err := base.PutObject(context.Background(), &s3lib.PutObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + Body: bytes.NewReader(data), + ContentLength: aws.Int64(int64(len(data))), + }) + return err +} + +// s3GetObject issues a standard S3 GET and reads the full body into dst. +func s3GetObject(base *s3lib.Client, bucket, key string, dst []byte) error { + out, err := base.GetObject(context.Background(), &s3lib.GetObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }) + if err != nil { + return err + } + defer out.Body.Close() + // io.ReadFull only guarantees len(dst) bytes were read; it doesn't + // notice a larger object whose extra bytes are simply left unread. + // Compare against the reported object size so a too-small dst is + // caught instead of silently benchmarking a truncated prefix. + if out.ContentLength != nil && *out.ContentLength != int64(len(dst)) { + return fmt.Errorf("object size %d does not match expected size %d", *out.ContentLength, len(dst)) + } + _, err = io.ReadFull(out.Body, dst) + return err +} + +func newS3Client(endpoint, access, secret, region string) *s3lib.Client { + return s3lib.New(s3lib.Options{ + BaseEndpoint: aws.String(endpoint), + Region: region, + Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(access, secret, "")), + UsePathStyle: true, + }) +} + +func ensureBucket(c *s3lib.Client, bucket string) error { + _, err := c.HeadBucket(context.Background(), &s3lib.HeadBucketInput{Bucket: aws.String(bucket)}) + if err == nil { + return nil + } + var notFound *s3types.NotFound + if !errors.As(err, ¬Found) { + return fmt.Errorf("head bucket %q: %w", bucket, err) + } + _, err = c.CreateBucket(context.Background(), &s3lib.CreateBucketInput{ + Bucket: aws.String(bucket), + CreateBucketConfiguration: &s3types.CreateBucketConfiguration{}, + }) + if err != nil { + return fmt.Errorf("create bucket %q: %w", bucket, err) + } + fmt.Printf("cuobjtest: created bucket %q\n", bucket) + return nil +} + +// printIterationResult renders one benchmark iteration according to mode. +func printIterationResult(size int, r iterationResult) { + if *putOnly { + fmt.Printf("%-4d %-14s %-10s\n", r.index, fmtDur(r.putDur), fmtGBps(size, r.putDur)) + return + } + if *getOnly { + fmt.Printf("%-4d %-14s %-10s\n", r.index, fmtDur(r.getDur), fmtGBps(size, r.getDur)) + return + } + fmt.Printf("%-4d %-14s %-10s %-14s %-10s %s\n", + r.index, + fmtDur(r.putDur), fmtGBps(size, r.putDur), + fmtDur(r.getDur), fmtGBps(size, r.getDur), + r.status) +} + +// fmtDur renders a human-friendly duration. +func fmtDur(d time.Duration) string { + ms := d.Seconds() * 1000 + if ms >= 1000 { + return fmt.Sprintf("%.2f s", d.Seconds()) + } + return fmt.Sprintf("%.2f ms", ms) +} + +// fmtGBps formats bytes-per-second throughput as decimal GB/s. +func fmtGBps(bytes int, d time.Duration) string { + if d <= 0 { + return "-" + } + return fmt.Sprintf("%.3f GB/s", float64(bytes)/d.Seconds()/1e9) +} + +// formatSize renders a byte size using binary units when evenly divisible. +func formatSize(n int) string { + switch { + case n >= 1<<30 && n%(1<<30) == 0: + return fmt.Sprintf("%d GiB", n>>30) + case n >= 1<<20 && n%(1<<20) == 0: + return fmt.Sprintf("%d MiB", n>>20) + case n >= 1<<10 && n%(1<<10) == 0: + return fmt.Sprintf("%d KiB", n>>10) + default: + return fmt.Sprintf("%d B", n) + } +} + +// durStats returns average, minimum, and maximum durations. +func durStats(ds []time.Duration) (avg, min, max time.Duration) { + min = time.Duration(math.MaxInt64) + var sum time.Duration + for _, d := range ds { + sum += d + if d < min { + min = d + } + if d > max { + max = d + } + } + avg = sum / time.Duration(len(ds)) + return +} + +// sumDurs returns the sum of the given durations. +func sumDurs(ds []time.Duration) time.Duration { + var sum time.Duration + for _, d := range ds { + sum += d + } + return sum +} + +// parseSize parses byte sizes like 64MiB, 1GiB, or 1000000. +func parseSize(s string) (int, error) { + units := []struct { + suffix string + mult int64 + }{ + {"GiB", 1 << 30}, {"GB", 1_000_000_000}, + {"MiB", 1 << 20}, {"MB", 1_000_000}, + {"KiB", 1 << 10}, {"KB", 1_000}, + {"G", 1 << 30}, {"M", 1 << 20}, {"K", 1 << 10}, + {"B", 1}, + } + upper := strings.ToUpper(strings.TrimSpace(s)) + for _, u := range units { + if strings.HasSuffix(upper, strings.ToUpper(u.suffix)) { + numStr := strings.TrimSpace(s[:len(s)-len(u.suffix)]) + n, err := strconv.ParseFloat(numStr, 64) + if err != nil { + return 0, fmt.Errorf("invalid size %q", s) + } + return int(n * float64(u.mult)), nil + } + } + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil { + return 0, fmt.Errorf("invalid size %q", s) + } + return n, nil +} + +// fatalf prints an error and exits with status code 1. +func fatalf(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/cmd/cuobjtest/mode_gpu.go b/cmd/cuobjtest/mode_gpu.go new file mode 100644 index 00000000..ece54e95 --- /dev/null +++ b/cmd/cuobjtest/mode_gpu.go @@ -0,0 +1,7 @@ +//go:build !cuobjclient_host + +package main + +func runModeLabel() string { + return "real-cuda-cuobject-token" +} diff --git a/cmd/cuobjtest/mode_host.go b/cmd/cuobjtest/mode_host.go new file mode 100644 index 00000000..8d2a2cba --- /dev/null +++ b/cmd/cuobjtest/mode_host.go @@ -0,0 +1,7 @@ +//go:build cuobjclient_host + +package main + +func runModeLabel() string { + return "host-memory-cuobject-token" +} diff --git a/cmd/versitygw/admin.go b/cmd/internal/gwcli/admin.go similarity index 99% rename from cmd/versitygw/admin.go rename to cmd/internal/gwcli/admin.go index 69b178f5..c4a78d07 100644 --- a/cmd/versitygw/admin.go +++ b/cmd/internal/gwcli/admin.go @@ -12,7 +12,7 @@ // specific language governing permissions and limitations // under the License. -package main +package gwcli import ( "bytes" @@ -47,7 +47,9 @@ var ( allowInsecure bool ) -func adminCommand() *cli.Command { +// AdminCommand returns the "admin" subcommand, common to all versitygw +// binaries. +func AdminCommand() *cli.Command { return &cli.Command{ Name: "admin", Usage: "admin CLI tool", @@ -287,10 +289,10 @@ func getAdminCreds() (string, string, error) { // Fallbacks to root user credentials if access == "" { - access = rootUserAccess + access = RootUserAccess } if secret == "" { - secret = rootUserSecret + secret = RootUserSecret } if access == "" { diff --git a/cmd/versitygw/azure.go b/cmd/internal/gwcli/azure.go similarity index 90% rename from cmd/versitygw/azure.go rename to cmd/internal/gwcli/azure.go index ea7f60d4..37c8b696 100644 --- a/cmd/versitygw/azure.go +++ b/cmd/internal/gwcli/azure.go @@ -12,7 +12,7 @@ // specific language governing permissions and limitations // under the License. -package main +package gwcli import ( "fmt" @@ -25,7 +25,9 @@ var ( azAccount, azKey, azServiceURL, azSASToken string ) -func azureCommand() *cli.Command { +// AzureCommand returns the "azure" subcommand, common to all versitygw +// binaries. +func AzureCommand() *cli.Command { return &cli.Command{ Name: "azure", Usage: "azure blob storage backend", @@ -65,10 +67,10 @@ func azureCommand() *cli.Command { } func runAzure(ctx *cli.Context) error { - be, err := azure.New(azAccount, azKey, azServiceURL, azSASToken, copyObjectThreshold) + be, err := azure.New(azAccount, azKey, azServiceURL, azSASToken, CopyObjectThreshold) if err != nil { return fmt.Errorf("init azure: %w", err) } - return runGateway(ctx.Context, be) + return RunGateway(ctx.Context, be) } diff --git a/cmd/internal/gwcli/gwcli.go b/cmd/internal/gwcli/gwcli.go new file mode 100644 index 00000000..708d7c95 --- /dev/null +++ b/cmd/internal/gwcli/gwcli.go @@ -0,0 +1,46 @@ +// Copyright 2023 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 gwcli holds the versitygw CLI subcommands and state shared by all +// versitygw-based main packages (cmd/versitygw, cmd/vgwrdma, ...). Hosting +// binaries wire up their backend-specific flags/commands in their own main +// package, and delegate the identical, backend-agnostic subcommands here. +package gwcli + +import ( + "context" + + "github.com/versity/versitygw/backend" +) + +// RootUserAccess and RootUserSecret are the root user credentials parsed by +// the hosting binary's --access/--secret flags. The admin subcommand falls +// back to these when no admin-specific credentials are provided. +var ( + RootUserAccess string + RootUserSecret string +) + +// CopyObjectThreshold is the maximum allowed source object size in bytes for +// CopyObject, populated by the hosting binary's --copy-object-threshold flag. +var CopyObjectThreshold int64 + +// DisableStrictBucketNames allows relaxed bucket naming, populated by the +// hosting binary's --disable-strict-bucket-names flag. +var DisableStrictBucketNames bool + +// RunGateway starts the S3 gateway server for the given backend. The hosting +// binary's main package must set this before running any command that can +// launch a backend (azure, plugin, s3). +var RunGateway func(ctx context.Context, be backend.Backend) error diff --git a/cmd/versitygw/plugin.go b/cmd/internal/gwcli/plugin.go similarity index 91% rename from cmd/versitygw/plugin.go rename to cmd/internal/gwcli/plugin.go index c864a66a..8064b683 100644 --- a/cmd/versitygw/plugin.go +++ b/cmd/internal/gwcli/plugin.go @@ -12,7 +12,7 @@ // specific language governing permissions and limitations // under the License. -package main +package gwcli import ( "errors" @@ -23,7 +23,9 @@ import ( "github.com/versity/versitygw/plugins" ) -func pluginCommand() *cli.Command { +// PluginCommand returns the "plugin" subcommand, common to all versitygw +// binaries. +func PluginCommand() *cli.Command { return &cli.Command{ Name: "plugin", Usage: "load a backend from a plugin", @@ -71,5 +73,5 @@ func runPluginBackend(ctx *cli.Context) error { return err } - return runGateway(ctx.Context, be) + return RunGateway(ctx.Context, be) } diff --git a/cmd/versitygw/posix.go b/cmd/internal/gwcli/posix.go similarity index 96% rename from cmd/versitygw/posix.go rename to cmd/internal/gwcli/posix.go index d7f98d95..714a9b5e 100644 --- a/cmd/versitygw/posix.go +++ b/cmd/internal/gwcli/posix.go @@ -12,7 +12,7 @@ // specific language governing permissions and limitations // under the License. -package main +package gwcli import ( "fmt" @@ -40,7 +40,9 @@ var ( dataIntegrityEtag bool ) -func posixCommand() *cli.Command { +// PosixCommand returns the "posix" subcommand, common to all versitygw +// binaries. +func PosixCommand() *cli.Command { return &cli.Command{ Name: "posix", Usage: "posix filesystem storage backend", @@ -176,10 +178,10 @@ func runPosix(ctx *cli.Context) error { ForceNoTmpFile: forceNoTmpFile, ForceNoCopyFileRange: forceNoCopyFileRange, EnableODirect: enableODirect, - ValidateBucketNames: disableStrictBucketNames, + ValidateBucketNames: DisableStrictBucketNames, Concurrency: actionsConcurrency, IOBufferSize: ioBufferSize, - CopyObjectThreshold: copyObjectThreshold, + CopyObjectThreshold: CopyObjectThreshold, DefaultEtag: defaultEtag, DataIntegrityEtag: dataIntegrityEtag, } @@ -208,5 +210,5 @@ func runPosix(ctx *cli.Context) error { return fmt.Errorf("failed to init posix backend: %w", err) } - return runGateway(ctx.Context, be) + return RunGateway(ctx.Context, be) } diff --git a/cmd/versitygw/s3.go b/cmd/internal/gwcli/s3.go similarity index 96% rename from cmd/versitygw/s3.go rename to cmd/internal/gwcli/s3.go index 7b7808e4..3723c7b7 100644 --- a/cmd/versitygw/s3.go +++ b/cmd/internal/gwcli/s3.go @@ -12,7 +12,7 @@ // specific language governing permissions and limitations // under the License. -package main +package gwcli import ( "fmt" @@ -36,7 +36,8 @@ var ( s3proxyGCSCompatibility bool ) -func s3Command() *cli.Command { +// S3Command returns the "s3" subcommand, common to all versitygw binaries. +func S3Command() *cli.Command { return &cli.Command{ Name: "s3", Usage: "s3 storage backend", @@ -139,5 +140,5 @@ func runS3(ctx *cli.Context) error { if err != nil { return fmt.Errorf("init s3 backend: %w", err) } - return runGateway(ctx.Context, be) + return RunGateway(ctx.Context, be) } diff --git a/cmd/versitygw/scoutfs.go b/cmd/internal/gwcli/scoutfs.go similarity index 94% rename from cmd/versitygw/scoutfs.go rename to cmd/internal/gwcli/scoutfs.go index 6c2a24f7..f48d4fc7 100644 --- a/cmd/versitygw/scoutfs.go +++ b/cmd/internal/gwcli/scoutfs.go @@ -12,7 +12,7 @@ // specific language governing permissions and limitations // under the License. -package main +package gwcli import ( "fmt" @@ -29,7 +29,9 @@ var ( setProjectID bool ) -func scoutfsCommand() *cli.Command { +// ScoutfsCommand returns the "scoutfs" subcommand, common to all versitygw +// binaries. +func ScoutfsCommand() *cli.Command { return &cli.Command{ Name: "scoutfs", Usage: "scoutfs filesystem storage backend", @@ -143,10 +145,10 @@ func runScoutfs(ctx *cli.Context) error { opts.NewDirPerm = fs.FileMode(dirPerms) opts.DisableNoArchive = disableNoArchive opts.VersioningDir = versioningDir - opts.ValidateBucketNames = disableStrictBucketNames + opts.ValidateBucketNames = DisableStrictBucketNames opts.SetProjectID = setProjectID opts.Concurrency = actionsConcurrency - opts.CopyObjectThreshold = copyObjectThreshold + opts.CopyObjectThreshold = CopyObjectThreshold opts.DefaultEtag = defaultEtag opts.DataIntegrityEtag = dataIntegrityEtag @@ -155,5 +157,5 @@ func runScoutfs(ctx *cli.Context) error { return fmt.Errorf("init scoutfs: %v", err) } - return runGateway(ctx.Context, be) + return RunGateway(ctx.Context, be) } diff --git a/cmd/versitygw/signal.go b/cmd/internal/gwcli/signal.go similarity index 68% rename from cmd/versitygw/signal.go rename to cmd/internal/gwcli/signal.go index 9b2c2d30..fd9f0add 100644 --- a/cmd/versitygw/signal.go +++ b/cmd/internal/gwcli/signal.go @@ -12,7 +12,7 @@ // specific language governing permissions and limitations // under the License. -package main +package gwcli import ( "fmt" @@ -22,11 +22,15 @@ import ( ) var ( - sigDone = make(chan struct{}, 1) - sigHup = make(chan struct{}, 1) + // SigDone is signaled once on SIGINT/SIGTERM to begin shutdown. + SigDone = make(chan struct{}, 1) + // SigHup is signaled on every SIGHUP to trigger a config reload. + SigHup = make(chan struct{}, 1) ) -func setupSignalHandler() { +// SetupSignalHandler starts a goroutine that translates SIGINT/SIGTERM into a +// single SigDone notification and SIGHUP into repeated SigHup notifications. +func SetupSignalHandler() { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) @@ -35,9 +39,9 @@ func setupSignalHandler() { fmt.Fprintf(os.Stderr, "caught signal %v\n", sig) switch sig { case syscall.SIGINT, syscall.SIGTERM: - sigDone <- struct{}{} + SigDone <- struct{}{} case syscall.SIGHUP: - sigHup <- struct{}{} + SigHup <- struct{}{} } } }() diff --git a/cmd/versitygw/utils.go b/cmd/internal/gwcli/utils.go similarity index 97% rename from cmd/versitygw/utils.go rename to cmd/internal/gwcli/utils.go index 002f4166..5f259d76 100644 --- a/cmd/versitygw/utils.go +++ b/cmd/internal/gwcli/utils.go @@ -12,7 +12,7 @@ // specific language governing permissions and limitations // under the License. -package main +package gwcli import ( "encoding/json" @@ -27,7 +27,9 @@ import ( "github.com/versity/versitygw/s3event" ) -func utilsCommand() *cli.Command { +// UtilsCommand returns the "utils" subcommand, common to all versitygw +// binaries. +func UtilsCommand() *cli.Command { return &cli.Command{ Name: "utils", Usage: "utility helper CLI tool", diff --git a/cmd/versitygw/gateway_test.go b/cmd/versitygw/gateway_test.go index 929b7733..9740342d 100644 --- a/cmd/versitygw/gateway_test.go +++ b/cmd/versitygw/gateway_test.go @@ -11,6 +11,7 @@ import ( "github.com/versity/versitygw/backend/meta" "github.com/versity/versitygw/backend/posix" + "github.com/versity/versitygw/cmd/internal/gwcli" "github.com/versity/versitygw/tests/integration" ) @@ -28,14 +29,14 @@ func initEnv(dir string) { region = "us-east-1" // server - rootUserAccess = "user" - rootUserSecret = "pass" + gwcli.RootUserAccess = "user" + gwcli.RootUserSecret = "pass" iamDir = dir maxConnections = 250000 maxRequests = 100000 ports = []string{"127.0.0.1:7070"} mpMaxParts = 10000 - copyObjectThreshold = 5 * 1024 * 1024 * 1024 + gwcli.CopyObjectThreshold = 5 * 1024 * 1024 * 1024 // client awsID = "user" diff --git a/cmd/versitygw/main.go b/cmd/versitygw/main.go index aea7d60b..743b9142 100644 --- a/cmd/versitygw/main.go +++ b/cmd/versitygw/main.go @@ -24,6 +24,7 @@ import ( "github.com/urfave/cli/v2" "github.com/versity/versitygw/backend" + "github.com/versity/versitygw/cmd/internal/gwcli" "github.com/versity/versitygw/embedgw" "github.com/versity/versitygw/s3api/utils" ) @@ -31,8 +32,6 @@ import ( var ( ports []string admPorts []string - rootUserAccess string - rootUserSecret string region string maxConnections, maxRequests int adminMaxConnections, adminMaxRequests int @@ -54,7 +53,6 @@ var ( pprof string quiet bool readonly bool - disableStrictBucketNames bool iamDir string ldapURL, ldapBindDN, ldapPassword string ldapQueryBase, ldapObjClasses string @@ -97,7 +95,6 @@ var ( websiteNoTLS bool disableACLs bool mpMaxParts int - copyObjectThreshold int64 socketPerm string ) @@ -111,24 +108,25 @@ var ( ) func main() { - setupSignalHandler() + gwcli.SetupSignalHandler() + gwcli.RunGateway = runGateway app := initApp() app.Commands = []*cli.Command{ - posixCommand(), - scoutfsCommand(), - s3Command(), - azureCommand(), - pluginCommand(), - adminCommand(), + gwcli.PosixCommand(), + gwcli.ScoutfsCommand(), + gwcli.S3Command(), + gwcli.AzureCommand(), + gwcli.PluginCommand(), + gwcli.AdminCommand(), testCommand(), - utilsCommand(), + gwcli.UtilsCommand(), } ctx, cancel := context.WithCancel(context.Background()) go func() { - <-sigDone + <-gwcli.SigDone fmt.Fprintf(os.Stderr, "terminating signal caught, shutting down\n") cancel() }() @@ -282,14 +280,14 @@ func initFlags() []cli.Flag { Usage: "root user access key", EnvVars: []string{"ROOT_ACCESS_KEY_ID", "ROOT_ACCESS_KEY"}, Aliases: []string{"a"}, - Destination: &rootUserAccess, + Destination: &gwcli.RootUserAccess, }, &cli.StringFlag{ Name: "secret", Usage: "root user secret access key", EnvVars: []string{"ROOT_SECRET_ACCESS_KEY", "ROOT_SECRET_KEY"}, Aliases: []string{"s"}, - Destination: &rootUserSecret, + Destination: &gwcli.RootUserSecret, }, &cli.StringFlag{ Name: "region", @@ -732,7 +730,7 @@ func initFlags() []cli.Flag { Name: "disable-strict-bucket-names", Usage: "allow relaxed bucket naming (disables strict validation checks)", EnvVars: []string{"VGW_DISABLE_STRICT_BUCKET_NAMES"}, - Destination: &disableStrictBucketNames, + Destination: &gwcli.DisableStrictBucketNames, }, &cli.StringFlag{ Name: "metrics-service-name", @@ -797,7 +795,7 @@ func initFlags() []cli.Flag { Usage: "maximum allowed source object size in bytes for CopyObject; objects larger than this are rejected", EnvVars: []string{"VGW_COPY_OBJECT_THRESHOLD"}, Value: 5 * 1024 * 1024 * 1024, - Destination: ©ObjectThreshold, + Destination: &gwcli.CopyObjectThreshold, }, &cli.StringFlag{ Name: "socket-perm", @@ -820,13 +818,13 @@ func runGateway(ctx context.Context, be backend.Backend) error { }() } - if copyObjectThreshold < 1 { + if gwcli.CopyObjectThreshold < 1 { return fmt.Errorf("copy-object-threshold must be positive") } return embedgw.RunVersityGW(ctx, be, &embedgw.Config{ - RootUserAccess: rootUserAccess, - RootUserSecret: rootUserSecret, + RootUserAccess: gwcli.RootUserAccess, + RootUserSecret: gwcli.RootUserSecret, Region: region, Ports: ports, AdminPorts: admPorts, @@ -846,7 +844,7 @@ func runGateway(ctx context.Context, be backend.Backend) error { Readonly: readonly, KeepAlive: keepAlive, DisableACLs: disableACLs, - DisableStrictBucketNames: disableStrictBucketNames, + DisableStrictBucketNames: gwcli.DisableStrictBucketNames, VirtualDomain: virtualDomain, HealthPath: healthPath, SocketPerm: socketPerm, @@ -919,7 +917,7 @@ func runGateway(ctx context.Context, be backend.Backend) error { WebsiteCertFile: websiteCertFile, WebsiteKeyFile: websiteKeyFile, WebsiteNoTLS: websiteNoTLS, - SigHup: sigHup, + SigHup: gwcli.SigHup, Version: Version, Build: Build, BuildTime: BuildTime, diff --git a/cmd/vgwrdma/main.go b/cmd/vgwrdma/main.go new file mode 100644 index 00000000..00db4c25 --- /dev/null +++ b/cmd/vgwrdma/main.go @@ -0,0 +1,1064 @@ +// Copyright 2023 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 main + +import ( + "context" + "fmt" + "log" + "math" + "net/http" + _ "net/http/pprof" + "os" + "strings" + + "github.com/urfave/cli/v2" + "github.com/versity/versitygw/backend" + "github.com/versity/versitygw/cmd/internal/gwcli" + "github.com/versity/versitygw/cubackend" + "github.com/versity/versitygw/cumiddleware" + "github.com/versity/versitygw/embedgw" + "github.com/versity/versitygw/rdma" + "github.com/versity/versitygw/s3api" + "github.com/versity/versitygw/s3api/utils" +) + +var ( + ports []string + admPorts []string + region string + maxConnections, maxRequests int + adminMaxConnections, adminMaxRequests int + corsAllowOrigin string + admCertFile, admKeyFile string + certFile, keyFile string + kafkaURL, kafkaTopic, kafkaKey string + natsURL, natsTopic string + rabbitmqURL, rabbitmqExchange string + rabbitmqRoutingKey string + eventWebhookURL string + eventConfigFilePath string + logWebhookURL, accessLog string + adminLogFile string + healthPath string + virtualDomain string + debug bool + keepAlive bool + pprof string + quiet bool + readonly bool + iamDir string + ldapURL, ldapBindDN, ldapPassword string + ldapQueryBase, ldapObjClasses string + ldapAccessAtr, ldapSecAtr, ldapRoleAtr string + ldapUserIdAtr, ldapGroupIdAtr string + ldapProjectIdAtr string + ldapTLSSkipVerify bool + vaultEndpointURL, vaultNamespace string + vaultSecretStoragePath string + vaultSecretStorageNamespace string + vaultAuthMethod, vaultAuthNamespace string + vaultMountPath string + vaultRootToken, vaultRoleId string + vaultRoleSecret, vaultServerCert string + vaultClientCert, vaultClientCertKey string + s3IamAccess, s3IamSecret string + s3IamRegion, s3IamBucket string + s3IamEndpoint string + s3IamSslNoVerify bool + iamCacheDisable bool + iamCacheTTL int + iamCachePrune int + metricsService string + statsdServers string + dogstatsServers string + ipaHost, ipaVaultName string + ipaUser, ipaPassword string + ipaInsecure bool + iamDebug bool + webuiPorts []string + webuiCertFile, webuiKeyFile string + webuiNoTLS bool + webuiGateways []string + webuiAdminGateways []string + webuiPathPrefix string + webuiS3Prefix string + websitePorts []string + websiteDomain string + websiteCertFile, websiteKeyFile string + websiteNoTLS bool + disableACLs bool + mpMaxParts int + socketPerm string + rdmaIP string + rdmaPort uint + poolBufSize int + poolBufCount int + rdmaDCKey uint64 + rdmaNumDCIs int + rdmaCQDepth uint + rdmaRetryCount uint + rdmaTunablesSet bool +) + +var ( + // Version is the latest tag (set within Makefile) + Version = "git" + // Build is the commit hash (set within Makefile) + Build = "norev" + // BuildTime is the date/time of build (set within Makefile) + BuildTime = "none" +) + +// gatewayCommands are the subcommands that call gwcli.RunGateway (and +// therefore need --rdma-ip); admin, utils, help, and version do not. +var gatewayCommands = map[string]bool{ + "posix": true, + "scoutfs": true, + "s3": true, + "azure": true, + "plugin": true, +} + +func main() { + gwcli.SetupSignalHandler() + gwcli.RunGateway = runGateway + + app := initApp() + + app.Commands = []*cli.Command{ + gwcli.PosixCommand(), + gwcli.ScoutfsCommand(), + gwcli.S3Command(), + gwcli.AzureCommand(), + gwcli.PluginCommand(), + gwcli.AdminCommand(), + gwcli.UtilsCommand(), + } + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + <-gwcli.SigDone + fmt.Fprintf(os.Stderr, "terminating signal caught, shutting down\n") + cancel() + }() + + if err := app.RunContext(ctx, os.Args); err != nil { + log.Fatal(err) + } +} + +func initApp() *cli.App { + return &cli.App{ + EnableBashCompletion: true, + Usage: "Versity S3 Gateway", + Description: `The Versity S3 Gateway is an S3 protocol translator that allows an S3 client +to access the supported backend storage as if it was a native S3 service. +VersityGW is an open-source project licensed under the Apache 2.0 License. The +source code is hosted on GitHub at https://github.com/versity/versitygw, and +documentation can be found in the GitHub wiki.`, + Copyright: "Copyright (c) 2023-2026 Versity Software", + Before: func(ctx *cli.Context) error { + // Initialize global variables from context (including default values) + ports = ctx.StringSlice("port") + webuiPorts = ctx.StringSlice("webui") + admPorts = ctx.StringSlice("admin-port") + webuiGateways = ctx.StringSlice("webui-gateways") + webuiAdminGateways = ctx.StringSlice("webui-admin-gateways") + webuiPathPrefix = ctx.String("webui-path-prefix") + websitePorts = ctx.StringSlice("website") + + // Resolve relative UNIX socket paths to absolute before any backend + // (e.g. posix) can change the working directory via os.Chdir. + var err error + if ports, err = utils.AbsSocketPaths(ports); err != nil { + return err + } + if admPorts, err = utils.AbsSocketPaths(admPorts); err != nil { + return err + } + if webuiPorts, err = utils.AbsSocketPaths(webuiPorts); err != nil { + return err + } + if websitePorts, err = utils.AbsSocketPaths(websitePorts); err != nil { + return err + } + + rdmaTunablesSet = ctx.IsSet("rdma-dc-key") || + ctx.IsSet("rdma-num-dcis") || + ctx.IsSet("rdma-cq-depth") || + ctx.IsSet("rdma-retry-count") + + // Only commands that actually start a gateway need --rdma-ip; admin, + // utils, help, and version subcommands print output and exit without + // ever calling runGateway. + if gatewayCommands[ctx.Args().First()] && strings.TrimSpace(rdmaIP) == "" { + return fmt.Errorf("rdma-ip is required") + } + return nil + }, + Action: func(ctx *cli.Context) error { + return ctx.App.Command("help").Run(ctx) + }, + Flags: initFlags(), + } +} + +func initFlags() []cli.Flag { + return []cli.Flag{ + &cli.BoolFlag{ + Name: "version", + Usage: "list versitygw version", + Aliases: []string{"v"}, + Action: func(*cli.Context, bool) error { + fmt.Println("Version :", Version) + fmt.Println("Build :", Build) + fmt.Println("BuildTime:", BuildTime) + os.Exit(0) + return nil + }, + }, + &cli.StringSliceFlag{ + Name: "port", + Usage: "gateway listen address: :, :, /path/to/socket for file-backed UNIX sockets, or @name for Linux abstract namespace sockets (can be specified multiple times for listening on multiple addresses)", + EnvVars: []string{"VGW_PORT"}, + Value: cli.NewStringSlice(":7070"), + Aliases: []string{"p"}, + }, + &cli.StringSliceFlag{ + Name: "webui", + Usage: "enable WebUI server on the specified listen address (e.g. ':7071', '127.0.0.1:7071', 'localhost:7071', '/run/vgw/webui.sock'; supports the same UNIX socket forms as --port; can be specified multiple times for listening on multiple addresses; disabled when omitted)", + EnvVars: []string{"VGW_WEBUI_PORT"}, + }, + &cli.StringFlag{ + Name: "webui-cert", + Usage: "TLS cert file for WebUI (defaults to --cert value when WebUI is enabled)", + EnvVars: []string{"VGW_WEBUI_CERT"}, + Destination: &webuiCertFile, + }, + &cli.StringFlag{ + Name: "webui-key", + Usage: "TLS key file for WebUI (defaults to --key value when WebUI is enabled)", + EnvVars: []string{"VGW_WEBUI_KEY"}, + Destination: &webuiKeyFile, + }, + &cli.BoolFlag{ + Name: "webui-no-tls", + Usage: "disable TLS for WebUI even if TLS is configured for the gateway", + EnvVars: []string{"VGW_WEBUI_NO_TLS"}, + Destination: &webuiNoTLS, + }, + &cli.StringSliceFlag{ + Name: "webui-gateways", + Usage: "override auto-detected S3 gateway URLs for WebUI (e.g. 'http://localhost:7070', 'https://s3.example.com'; can be specified multiple times)", + EnvVars: []string{"VGW_WEBUI_GATEWAYS"}, + }, + &cli.StringSliceFlag{ + Name: "webui-admin-gateways", + Usage: "override auto-detected admin gateway URLs for WebUI (e.g. 'http://localhost:7080', 'https://admin.example.com'; can be specified multiple times)", + EnvVars: []string{"VGW_WEBUI_ADMIN_GATEWAYS"}, + }, + &cli.StringFlag{ + Name: "webui-path-prefix", + Usage: "mount the WebUI under a path prefix (e.g. '/ui'); must be single segment path that starts with '/'", + EnvVars: []string{"VGW_WEBUI_PATH_PREFIX"}, + Destination: &webuiPathPrefix, + }, + &cli.StringFlag{ + Name: "webui-s3-prefix", + Usage: "mount the WebUI on the S3 port at the given path prefix (e.g. '/ui'); must start with '/', must not be '/', and must not end with '/'", + EnvVars: []string{"VGW_WEBUI_S3_PREFIX"}, + Destination: &webuiS3Prefix, + }, + &cli.StringSliceFlag{ + Name: "website", + Usage: "enable static website hosting endpoint on the specified listen address (e.g. ':8080'; same forms as --port; can be specified multiple times; requires --website-domain)", + EnvVars: []string{"VGW_WEBSITE_PORT"}, + }, + &cli.StringFlag{ + Name: "website-domain", + Usage: "base domain for website virtual-host routing (e.g. 'example.com'); host 'blog.example.com' serves bucket 'blog', host 'example.com' serves bucket 'example.com'; when omitted the full hostname is used as the bucket name (catch-all mode, buckets named as FQDNs)", + EnvVars: []string{"VGW_WEBSITE_DOMAIN"}, + Destination: &websiteDomain, + }, + &cli.StringFlag{ + Name: "website-cert", + Usage: "TLS cert file for website endpoint (defaults to --cert value when website is enabled)", + EnvVars: []string{"VGW_WEBSITE_CERT"}, + Destination: &websiteCertFile, + }, + &cli.StringFlag{ + Name: "website-key", + Usage: "TLS key file for website endpoint (defaults to --key value when website is enabled)", + EnvVars: []string{"VGW_WEBSITE_KEY"}, + Destination: &websiteKeyFile, + }, + &cli.BoolFlag{ + Name: "website-no-tls", + Usage: "disable TLS for website endpoint even if TLS is configured for the gateway", + EnvVars: []string{"VGW_WEBSITE_NO_TLS"}, + Destination: &websiteNoTLS, + }, + &cli.StringFlag{ + Name: "access", + Usage: "root user access key", + EnvVars: []string{"ROOT_ACCESS_KEY_ID", "ROOT_ACCESS_KEY"}, + Aliases: []string{"a"}, + Destination: &gwcli.RootUserAccess, + }, + &cli.StringFlag{ + Name: "secret", + Usage: "root user secret access key", + EnvVars: []string{"ROOT_SECRET_ACCESS_KEY", "ROOT_SECRET_KEY"}, + Aliases: []string{"s"}, + Destination: &gwcli.RootUserSecret, + }, + &cli.StringFlag{ + Name: "region", + Usage: "s3 region string", + EnvVars: []string{"VGW_REGION"}, + Value: "us-east-1", + Destination: ®ion, + Aliases: []string{"r"}, + }, + &cli.IntFlag{ + Name: "max-connections", + Usage: "maximum number of concurrent connections s3 api server may serve", + EnvVars: []string{"VGW_MAX_CONNECTIONS"}, + Value: 250000, + Destination: &maxConnections, + Aliases: []string{"mc"}, + }, + &cli.IntFlag{ + Name: "max-requests", + Usage: "maximum number of in-flight requests s3 api server may serve", + EnvVars: []string{"VGW_MAX_REQUESTS"}, + Value: 100000, + Destination: &maxRequests, + Aliases: []string{"mr"}, + }, + &cli.StringFlag{ + Name: "cors-allow-origin", + Usage: "default CORS Access-Control-Allow-Origin value (applied when no bucket CORS configuration exists, and for admin APIs)", + EnvVars: []string{"VGW_CORS_ALLOW_ORIGIN"}, + Destination: &corsAllowOrigin, + }, + &cli.StringFlag{ + Name: "cert", + Usage: "TLS cert file", + EnvVars: []string{"VGW_CERT"}, + Destination: &certFile, + }, + &cli.StringFlag{ + Name: "key", + Usage: "TLS key file", + EnvVars: []string{"VGW_KEY"}, + Destination: &keyFile, + }, + &cli.StringSliceFlag{ + Name: "admin-port", + Usage: "gateway admin server listen address: :, :, /path/to/socket for file-backed UNIX sockets, or @name for Linux abstract namespace sockets (can be specified multiple times for listening on multiple addresses)", + EnvVars: []string{"VGW_ADMIN_PORT"}, + Aliases: []string{"ap"}, + }, + &cli.IntFlag{ + Name: "admin-max-connections", + Usage: "maximum number of concurrent connections s3 admin server may handle", + EnvVars: []string{"VGW_ADMIN_MAX_CONNECTIONS"}, + Value: 250000, + Destination: &adminMaxConnections, + Aliases: []string{"amc"}, + }, + &cli.IntFlag{ + Name: "admin-max-requests", + Usage: "maximum number of in-flight requests s3 admin server may handle", + EnvVars: []string{"VGW_ADMIN_MAX_REQUESTS"}, + Value: 100000, + Destination: &adminMaxRequests, + Aliases: []string{"amr"}, + }, + &cli.StringFlag{ + Name: "admin-cert", + Usage: "TLS cert file for admin server", + EnvVars: []string{"VGW_ADMIN_CERT"}, + Destination: &admCertFile, + }, + &cli.StringFlag{ + Name: "admin-cert-key", + Usage: "TLS key file for admin server", + EnvVars: []string{"VGW_ADMIN_CERT_KEY"}, + Destination: &admKeyFile, + }, + &cli.BoolFlag{ + Name: "debug", + Usage: "enable debug output", + Value: false, + EnvVars: []string{"VGW_DEBUG"}, + Destination: &debug, + }, + &cli.StringFlag{ + Name: "pprof", + Usage: "enable pprof debug on specified port", + EnvVars: []string{"VGW_PPROF"}, + Destination: &pprof, + }, + &cli.BoolFlag{ + Name: "keep-alive", + Usage: "enable keep-alive connections", + EnvVars: []string{"VGW_KEEP_ALIVE"}, + Destination: &keepAlive, + }, + &cli.BoolFlag{ + Name: "quiet", + Usage: "silence stdout request logging output", + EnvVars: []string{"VGW_QUIET"}, + Destination: &quiet, + Aliases: []string{"q"}, + }, + &cli.StringFlag{ + Name: "virtual-domain", + Usage: "enables the virtual host style bucket addressing with the specified arg as the base domain", + EnvVars: []string{"VGW_VIRTUAL_DOMAIN"}, + Destination: &virtualDomain, + Aliases: []string{"vd"}, + }, + &cli.BoolFlag{ + Name: "disable-acl", + Usage: "disables gateway ACLs, by ignoring all ACL headers", + EnvVars: []string{"VGW_DISABLE_ACL"}, + Destination: &disableACLs, + Aliases: []string{"noacl"}, + }, + &cli.StringFlag{ + Name: "access-log", + Usage: "enable server access logging to specified file", + EnvVars: []string{"LOGFILE", "VGW_ACCESS_LOG"}, + Destination: &accessLog, + }, + &cli.StringFlag{ + Name: "admin-access-log", + Usage: "enable admin server access logging to specified file", + EnvVars: []string{"LOGFILE", "VGW_ADMIN_ACCESS_LOG"}, + Destination: &adminLogFile, + }, + &cli.StringFlag{ + Name: "log-webhook-url", + Usage: "webhook url to send the audit logs", + EnvVars: []string{"WEBHOOK", "VGW_LOG_WEBHOOK_URL"}, + Destination: &logWebhookURL, + }, + &cli.StringFlag{ + Name: "event-kafka-url", + Usage: "kafka server url to send the bucket notifications.", + EnvVars: []string{"VGW_EVENT_KAFKA_URL"}, + Destination: &kafkaURL, + Aliases: []string{"eku"}, + }, + &cli.StringFlag{ + Name: "event-kafka-topic", + Usage: "kafka server pub-sub topic to send the bucket notifications to", + EnvVars: []string{"VGW_EVENT_KAFKA_TOPIC"}, + Destination: &kafkaTopic, + Aliases: []string{"ekt"}, + }, + &cli.StringFlag{ + Name: "event-kafka-key", + Usage: "kafka server put-sub topic key to send the bucket notifications to", + EnvVars: []string{"VGW_EVENT_KAFKA_KEY"}, + Destination: &kafkaKey, + Aliases: []string{"ekk"}, + }, + &cli.StringFlag{ + Name: "event-nats-url", + Usage: "nats server url to send the bucket notifications", + EnvVars: []string{"VGW_EVENT_NATS_URL"}, + Destination: &natsURL, + Aliases: []string{"enu"}, + }, + &cli.StringFlag{ + Name: "event-nats-topic", + Usage: "nats server pub-sub topic to send the bucket notifications to", + EnvVars: []string{"VGW_EVENT_NATS_TOPIC"}, + Destination: &natsTopic, + Aliases: []string{"ent"}, + }, + &cli.StringFlag{ + Name: "event-rabbitmq-url", + Usage: "rabbitmq server url to send the bucket notifications (amqp or amqps scheme)", + EnvVars: []string{"VGW_EVENT_RABBITMQ_URL"}, + Destination: &rabbitmqURL, + Aliases: []string{"eru"}, + }, + &cli.StringFlag{ + Name: "event-rabbitmq-exchange", + Usage: "rabbitmq exchange to publish bucket notifications to (blank for default)", + EnvVars: []string{"VGW_EVENT_RABBITMQ_EXCHANGE"}, + Destination: &rabbitmqExchange, + Aliases: []string{"ere"}, + }, + &cli.StringFlag{ + Name: "event-rabbitmq-routing-key", + Usage: "rabbitmq routing key when publishing bucket notifications (defaults to bucket name when blank)", + EnvVars: []string{"VGW_EVENT_RABBITMQ_ROUTING_KEY"}, + Destination: &rabbitmqRoutingKey, + Aliases: []string{"errk"}, + }, + &cli.StringFlag{ + Name: "event-webhook-url", + Usage: "webhook url to send bucket notifications", + EnvVars: []string{"VGW_EVENT_WEBHOOK_URL"}, + Destination: &eventWebhookURL, + Aliases: []string{"ewu"}, + }, + &cli.StringFlag{ + Name: "event-filter", + Usage: "bucket event notifications filters configuration file path", + EnvVars: []string{"VGW_EVENT_FILTER"}, + Destination: &eventConfigFilePath, + Aliases: []string{"ef"}, + }, + &cli.StringFlag{ + Name: "iam-dir", + Usage: "if defined, run internal iam service within this directory", + EnvVars: []string{"VGW_IAM_DIR"}, + Destination: &iamDir, + }, + &cli.StringFlag{ + Name: "iam-ldap-url", + Usage: "ldap server url to store iam data", + EnvVars: []string{"VGW_IAM_LDAP_URL"}, + Destination: &ldapURL, + }, + &cli.StringFlag{ + Name: "iam-ldap-bind-dn", + Usage: "ldap server binding dn, example: 'cn=admin,dc=example,dc=com'", + EnvVars: []string{"VGW_IAM_LDAP_BIND_DN"}, + Destination: &ldapBindDN, + }, + &cli.StringFlag{ + Name: "iam-ldap-bind-pass", + Usage: "ldap server user password", + EnvVars: []string{"VGW_IAM_LDAP_BIND_PASS"}, + Destination: &ldapPassword, + }, + &cli.StringFlag{ + Name: "iam-ldap-query-base", + Usage: "ldap server destination query, example: 'ou=iam,dc=example,dc=com'", + EnvVars: []string{"VGW_IAM_LDAP_QUERY_BASE"}, + Destination: &ldapQueryBase, + }, + &cli.StringFlag{ + Name: "iam-ldap-object-classes", + Usage: "ldap server object classes used to store the data. provide it as comma separated string, example: 'top,person'", + EnvVars: []string{"VGW_IAM_LDAP_OBJECT_CLASSES"}, + Destination: &ldapObjClasses, + }, + &cli.StringFlag{ + Name: "iam-ldap-access-atr", + Usage: "ldap server user access key id attribute name", + EnvVars: []string{"VGW_IAM_LDAP_ACCESS_ATR"}, + Destination: &ldapAccessAtr, + }, + &cli.StringFlag{ + Name: "iam-ldap-secret-atr", + Usage: "ldap server user secret access key attribute name", + EnvVars: []string{"VGW_IAM_LDAP_SECRET_ATR"}, + Destination: &ldapSecAtr, + }, + &cli.StringFlag{ + Name: "iam-ldap-role-atr", + Usage: "ldap server user role attribute name", + EnvVars: []string{"VGW_IAM_LDAP_ROLE_ATR"}, + Destination: &ldapRoleAtr, + }, + &cli.StringFlag{ + Name: "iam-ldap-user-id-atr", + Usage: "ldap server user id attribute name", + EnvVars: []string{"VGW_IAM_LDAP_USER_ID_ATR"}, + Destination: &ldapUserIdAtr, + }, + &cli.StringFlag{ + Name: "iam-ldap-group-id-atr", + Usage: "ldap server user group id attribute name", + EnvVars: []string{"VGW_IAM_LDAP_GROUP_ID_ATR"}, + Destination: &ldapGroupIdAtr, + }, + &cli.StringFlag{ + Name: "iam-ldap-project-id-atr", + Usage: "ldap server user project id attribute name", + EnvVars: []string{"VGW_IAM_LDAP_PROJECT_ID_ATR"}, + Destination: &ldapProjectIdAtr, + }, + &cli.BoolFlag{ + Name: "iam-ldap-tls-skip-verify", + Usage: "disable TLS certificate verification for LDAP connections (insecure, for self-signed certificates)", + EnvVars: []string{"VGW_IAM_LDAP_TLS_SKIP_VERIFY"}, + Destination: &ldapTLSSkipVerify, + }, + &cli.StringFlag{ + Name: "iam-vault-endpoint-url", + Usage: "vault server url", + EnvVars: []string{"VGW_IAM_VAULT_ENDPOINT_URL"}, + Destination: &vaultEndpointURL, + }, + &cli.StringFlag{ + Name: "iam-vault-namespace", + Usage: "vault server namespace", + EnvVars: []string{"VGW_IAM_VAULT_NAMESPACE"}, + Destination: &vaultNamespace, + }, + &cli.StringFlag{ + Name: "iam-vault-secret-storage-path", + Usage: "vault server secret storage path", + EnvVars: []string{"VGW_IAM_VAULT_SECRET_STORAGE_PATH"}, + Destination: &vaultSecretStoragePath, + }, + &cli.StringFlag{ + Name: "iam-vault-secret-storage-namespace", + Usage: "vault server secret storage namespace", + EnvVars: []string{"VGW_IAM_VAULT_SECRET_STORAGE_NAMESPACE"}, + Destination: &vaultSecretStorageNamespace, + }, + &cli.StringFlag{ + Name: "iam-vault-auth-method", + Usage: "vault server auth method", + EnvVars: []string{"VGW_IAM_VAULT_AUTH_METHOD"}, + Destination: &vaultAuthMethod, + }, + &cli.StringFlag{ + Name: "iam-vault-auth-namespace", + Usage: "vault server auth namespace", + EnvVars: []string{"VGW_IAM_VAULT_AUTH_NAMESPACE"}, + Destination: &vaultAuthNamespace, + }, + &cli.StringFlag{ + Name: "iam-vault-mount-path", + Usage: "vault server mount path", + EnvVars: []string{"VGW_IAM_VAULT_MOUNT_PATH"}, + Destination: &vaultMountPath, + }, + &cli.StringFlag{ + Name: "iam-vault-root-token", + Usage: "vault server root token", + EnvVars: []string{"VGW_IAM_VAULT_ROOT_TOKEN"}, + Destination: &vaultRootToken, + }, + &cli.StringFlag{ + Name: "iam-vault-role-id", + Usage: "vault server user role id", + EnvVars: []string{"VGW_IAM_VAULT_ROLE_ID"}, + Destination: &vaultRoleId, + }, + &cli.StringFlag{ + Name: "iam-vault-role-secret", + Usage: "vault server user role secret", + EnvVars: []string{"VGW_IAM_VAULT_ROLE_SECRET"}, + Destination: &vaultRoleSecret, + }, + &cli.StringFlag{ + Name: "iam-vault-server_cert", + Usage: "vault server TLS certificate", + EnvVars: []string{"VGW_IAM_VAULT_SERVER_CERT"}, + Destination: &vaultServerCert, + }, + &cli.StringFlag{ + Name: "iam-vault-client_cert", + Usage: "vault client TLS certificate", + EnvVars: []string{"VGW_IAM_VAULT_CLIENT_CERT"}, + Destination: &vaultClientCert, + }, + &cli.StringFlag{ + Name: "iam-vault-client_cert_key", + Usage: "vault client TLS certificate key", + EnvVars: []string{"VGW_IAM_VAULT_CLIENT_CERT_KEY"}, + Destination: &vaultClientCertKey, + }, + &cli.StringFlag{ + Name: "s3-iam-access", + Usage: "s3 IAM access key", + EnvVars: []string{"VGW_S3_IAM_ACCESS_KEY"}, + Destination: &s3IamAccess, + }, + &cli.StringFlag{ + Name: "s3-iam-secret", + Usage: "s3 IAM secret key", + EnvVars: []string{"VGW_S3_IAM_SECRET_KEY"}, + Destination: &s3IamSecret, + }, + &cli.StringFlag{ + Name: "s3-iam-region", + Usage: "s3 IAM region", + EnvVars: []string{"VGW_S3_IAM_REGION"}, + Destination: &s3IamRegion, + Value: "us-east-1", + }, + &cli.StringFlag{ + Name: "s3-iam-bucket", + Usage: "s3 IAM bucket", + EnvVars: []string{"VGW_S3_IAM_BUCKET"}, + Destination: &s3IamBucket, + }, + &cli.StringFlag{ + Name: "s3-iam-endpoint", + Usage: "s3 IAM endpoint", + EnvVars: []string{"VGW_S3_IAM_ENDPOINT"}, + Destination: &s3IamEndpoint, + }, + &cli.BoolFlag{ + Name: "s3-iam-noverify", + Usage: "s3 IAM disable ssl verification", + EnvVars: []string{"VGW_S3_IAM_NO_VERIFY"}, + Destination: &s3IamSslNoVerify, + }, + &cli.BoolFlag{ + Name: "iam-cache-disable", + Usage: "disable local iam cache", + EnvVars: []string{"VGW_IAM_CACHE_DISABLE"}, + Destination: &iamCacheDisable, + }, + &cli.IntFlag{ + Name: "iam-cache-ttl", + Usage: "local iam cache entry ttl (seconds)", + EnvVars: []string{"VGW_IAM_CACHE_TTL"}, + Value: 120, + Destination: &iamCacheTTL, + }, + &cli.IntFlag{ + Name: "iam-cache-prune", + Usage: "local iam cache cleanup interval (seconds)", + EnvVars: []string{"VGW_IAM_CACHE_PRUNE"}, + Value: 3600, + Destination: &iamCachePrune, + }, + &cli.BoolFlag{ + Name: "iam-debug", + Usage: "enable IAM debug output", + Value: false, + EnvVars: []string{"VGW_IAM_DEBUG"}, + Destination: &iamDebug, + }, + &cli.StringFlag{ + Name: "health", + Usage: `health check endpoint path. Health endpoint will be configured on GET http method: GET + NOTICE: the path has to be specified with '/'. e.g /health`, + EnvVars: []string{"VGW_HEALTH"}, + Destination: &healthPath, + }, + &cli.BoolFlag{ + Name: "readonly", + Usage: "allow only read operations across all the gateway", + EnvVars: []string{"VGW_READ_ONLY"}, + Destination: &readonly, + }, + &cli.BoolFlag{ + Name: "disable-strict-bucket-names", + Usage: "allow relaxed bucket naming (disables strict validation checks)", + EnvVars: []string{"VGW_DISABLE_STRICT_BUCKET_NAMES"}, + Destination: &gwcli.DisableStrictBucketNames, + }, + &cli.StringFlag{ + Name: "metrics-service-name", + Usage: "service name tag for metrics, hostname if blank", + EnvVars: []string{"VGW_METRICS_SERVICE_NAME"}, + Aliases: []string{"msn"}, + Destination: &metricsService, + }, + &cli.StringFlag{ + Name: "metrics-statsd-servers", + Usage: "StatsD server urls comma separated. e.g. 'statsd1.example.com:8125,statsd2.example.com:8125'", + EnvVars: []string{"VGW_METRICS_STATSD_SERVERS"}, + Aliases: []string{"mss"}, + Destination: &statsdServers, + }, + &cli.StringFlag{ + Name: "metrics-dogstatsd-servers", + Usage: "DogStatsD server urls comma separated. e.g. '127.0.0.1:8125,dogstats.example.com:8125'", + EnvVars: []string{"VGW_METRICS_DOGSTATS_SERVERS"}, + Aliases: []string{"mds"}, + Destination: &dogstatsServers, + }, + &cli.StringFlag{ + Name: "ipa-host", + Usage: "FreeIPA server url e.g. https://ipa.example.test", + EnvVars: []string{"VGW_IPA_HOST"}, + Destination: &ipaHost, + }, + &cli.StringFlag{ + Name: "ipa-vault-name", + Usage: "A name of the user vault containing their secret", + EnvVars: []string{"VGW_IPA_VAULT_NAME"}, + Destination: &ipaVaultName, + }, + &cli.StringFlag{ + Name: "ipa-user", + Usage: "Username used to connect to FreeIPA (requires permissions to read user vault contents)", + EnvVars: []string{"VGW_IPA_USER"}, + Destination: &ipaUser, + }, + &cli.StringFlag{ + Name: "ipa-password", + Usage: "Password of the user used to connect to FreeIPA", + EnvVars: []string{"VGW_IPA_PASSWORD"}, + Destination: &ipaPassword, + }, + &cli.BoolFlag{ + Name: "ipa-insecure", + Usage: "Disable verify TLS certificate of FreeIPA server", + EnvVars: []string{"VGW_IPA_INSECURE"}, + Destination: &ipaInsecure, + }, + &cli.IntFlag{ + Name: "mp-max-parts", + Usage: "maximum number of parts allowed in a multipart upload", + EnvVars: []string{"VGW_MP_MAX_PARTS"}, + Value: 10000, + Destination: &mpMaxParts, + }, + &cli.Int64Flag{ + Name: "copy-object-threshold", + Usage: "maximum allowed source object size in bytes for CopyObject; objects larger than this are rejected", + EnvVars: []string{"VGW_COPY_OBJECT_THRESHOLD"}, + Value: 5 * 1024 * 1024 * 1024, + Destination: &gwcli.CopyObjectThreshold, + }, + &cli.StringFlag{ + Name: "rdma-ip", + Usage: "IP address for RDMA interface (required to enable cuObject RDMA backend)", + EnvVars: []string{"VGW_RDMA_IP"}, + Destination: &rdmaIP, + }, + &cli.UintFlag{ + Name: "rdma-port", + Usage: "port for RDMA listener", + EnvVars: []string{"VGW_RDMA_PORT"}, + Value: 19100, + Destination: &rdmaPort, + }, + &cli.IntFlag{ + Name: "pool-buf-size", + Usage: "size of each RDMA buffer in bytes", + EnvVars: []string{"VGW_POOL_BUF_SIZE"}, + Value: 1 << 30, + Destination: &poolBufSize, + }, + &cli.IntFlag{ + Name: "pool-buf-count", + Usage: "number of pre-allocated RDMA buffers", + EnvVars: []string{"VGW_POOL_BUF_COUNT"}, + Value: 4, + Destination: &poolBufCount, + }, + &cli.Uint64Flag{ + Name: "rdma-dc-key", + Usage: "InfiniBand DC security key (change in production)", + EnvVars: []string{"VGW_RDMA_DC_KEY"}, + Value: 0xffeeddcc, + Destination: &rdmaDCKey, + }, + &cli.IntFlag{ + Name: "rdma-num-dcis", + Usage: "number of Dynamic Connection Interfaces (max concurrent RDMA connections)", + EnvVars: []string{"VGW_RDMA_NUM_DCIS"}, + Value: 128, + Destination: &rdmaNumDCIs, + }, + &cli.UintFlag{ + Name: "rdma-cq-depth", + Usage: "completion queue depth", + EnvVars: []string{"VGW_RDMA_CQ_DEPTH"}, + Value: 640, + Destination: &rdmaCQDepth, + }, + &cli.UintFlag{ + Name: "rdma-retry-count", + Usage: "QP retry count (0-7)", + EnvVars: []string{"VGW_RDMA_RETRY_COUNT"}, + Value: 7, + Destination: &rdmaRetryCount, + }, + &cli.StringFlag{ + Name: "socket-perm", + Usage: "file permissions for file-backed UNIX domain sockets (octal, e.g. '0660'); ignored for TCP/IP and abstract namespace sockets", + EnvVars: []string{"VGW_SOCKET_PERM"}, + Destination: &socketPerm, + }, + } +} + +func runGateway(ctx context.Context, be backend.Backend) error { + if pprof != "" { + // Listen on the specified address for pprof debug endpoints. + // Point a browser to http:///debug/pprof/ + go func() { + log.Printf("pprof: listening on %s", pprof) + if err := http.ListenAndServe(pprof, nil); err != nil { + log.Printf("pprof: server exited: %v", err) + } + }() + } + + if gwcli.CopyObjectThreshold < 1 { + return fmt.Errorf("copy-object-threshold must be positive") + } + if rdmaPort > 65535 { + return fmt.Errorf("rdma-port %d is out of range (0-65535)", rdmaPort) + } + if rdmaRetryCount > 7 { + return fmt.Errorf("rdma-retry-count %d is out of range (0-7)", rdmaRetryCount) + } + if poolBufSize <= 0 { + return fmt.Errorf("pool-buf-size %d must be positive", poolBufSize) + } + if poolBufCount <= 0 { + return fmt.Errorf("pool-buf-count %d must be positive", poolBufCount) + } + if rdmaTunablesSet && rdmaNumDCIs <= 0 { + return fmt.Errorf("rdma-num-dcis %d must be positive", rdmaNumDCIs) + } + if rdmaTunablesSet && rdmaCQDepth > math.MaxUint32 { + return fmt.Errorf("rdma-cq-depth %d exceeds maximum %d", rdmaCQDepth, uint32(math.MaxUint32)) + } + + var s3Opts []s3api.Option + if rdmaIP != "" { + rdma.ConfigureTelemetry(debug) + + var tunables *rdma.RDMATunables + if rdmaTunablesSet { + t := rdma.DefaultRDMATunables() + t.DCKey = rdmaDCKey + t.NumDCIs = rdmaNumDCIs + t.CQDepth = uint32(rdmaCQDepth) + t.RetryCount = uint8(rdmaRetryCount) + tunables = &t + } + + cuserverBackend, err := cubackend.New(cubackend.CuServerOpts{ + RDMAIP: rdmaIP, + RDMAPort: uint16(rdmaPort), + PoolBufSize: poolBufSize, + PoolBufCount: poolBufCount, + RDMATunables: tunables, + }, be) + if err != nil { + return err + } + be = cuserverBackend + + s3Opts = append(s3Opts, s3api.WithMiddleware("/", cumiddleware.CuObjMiddleware)) + } + + return embedgw.RunVersityGW(ctx, be, &embedgw.Config{ + RootUserAccess: gwcli.RootUserAccess, + RootUserSecret: gwcli.RootUserSecret, + Region: region, + Ports: ports, + AdminPorts: admPorts, + MaxConnections: maxConnections, + MaxRequests: maxRequests, + AdminMaxConnections: adminMaxConnections, + AdminMaxRequests: adminMaxRequests, + MultipartMaxParts: mpMaxParts, + CertFile: certFile, + KeyFile: keyFile, + AdminCertFile: admCertFile, + AdminKeyFile: admKeyFile, + CORSAllowOrigin: corsAllowOrigin, + Debug: debug, + IAMDebug: iamDebug, + Quiet: quiet, + Readonly: readonly, + KeepAlive: keepAlive, + DisableACLs: disableACLs, + DisableStrictBucketNames: gwcli.DisableStrictBucketNames, + VirtualDomain: virtualDomain, + HealthPath: healthPath, + SocketPerm: socketPerm, + IAMDir: iamDir, + LDAPServerURL: ldapURL, + LDAPBindDN: ldapBindDN, + LDAPPassword: ldapPassword, + LDAPQueryBase: ldapQueryBase, + LDAPObjClasses: ldapObjClasses, + LDAPAccessAttr: ldapAccessAtr, + LDAPSecretAttr: ldapSecAtr, + LDAPRoleAttr: ldapRoleAtr, + LDAPUserIDAttr: ldapUserIdAtr, + LDAPGroupIDAttr: ldapGroupIdAtr, + LDAPProjectIDAttr: ldapProjectIdAtr, + LDAPTLSSkipVerify: ldapTLSSkipVerify, + VaultEndpointURL: vaultEndpointURL, + VaultNamespace: vaultNamespace, + VaultSecretStoragePath: vaultSecretStoragePath, + VaultSecretStorageNamespace: vaultSecretStorageNamespace, + VaultAuthMethod: vaultAuthMethod, + VaultAuthNamespace: vaultAuthNamespace, + VaultMountPath: vaultMountPath, + VaultRootToken: vaultRootToken, + VaultRoleID: vaultRoleId, + VaultRoleSecret: vaultRoleSecret, + VaultServerCert: vaultServerCert, + VaultClientCert: vaultClientCert, + VaultClientCertKey: vaultClientCertKey, + S3IAMAccess: s3IamAccess, + S3IAMSecret: s3IamSecret, + S3IAMRegion: s3IamRegion, + S3IAMBucket: s3IamBucket, + S3IAMEndpoint: s3IamEndpoint, + S3IAMDisableSSLVerify: s3IamSslNoVerify, + IAMCacheDisable: iamCacheDisable, + IAMCacheTTL: iamCacheTTL, + IAMCachePrune: iamCachePrune, + IpaHost: ipaHost, + IpaVaultName: ipaVaultName, + IpaUser: ipaUser, + IpaPassword: ipaPassword, + IpaInsecure: ipaInsecure, + AccessLog: accessLog, + LogWebhookURL: logWebhookURL, + AdminLogFile: adminLogFile, + MetricsService: metricsService, + StatsdServers: statsdServers, + DogstatsServers: dogstatsServers, + KafkaURL: kafkaURL, + KafkaTopic: kafkaTopic, + KafkaKey: kafkaKey, + NatsURL: natsURL, + NatsTopic: natsTopic, + RabbitmqURL: rabbitmqURL, + RabbitmqExchange: rabbitmqExchange, + RabbitmqRoutingKey: rabbitmqRoutingKey, + EventWebhookURL: eventWebhookURL, + EventConfigFilePath: eventConfigFilePath, + WebuiPorts: webuiPorts, + WebuiCertFile: webuiCertFile, + WebuiKeyFile: webuiKeyFile, + WebuiNoTLS: webuiNoTLS, + WebuiGateways: webuiGateways, + WebuiAdminGateways: webuiAdminGateways, + WebuiPathPrefix: webuiPathPrefix, + WebuiS3Prefix: webuiS3Prefix, + WebsitePorts: websitePorts, + WebsiteDomain: websiteDomain, + WebsiteCertFile: websiteCertFile, + WebsiteKeyFile: websiteKeyFile, + WebsiteNoTLS: websiteNoTLS, + SigHup: gwcli.SigHup, + S3Options: s3Opts, + Version: Version, + Build: Build, + BuildTime: BuildTime, + }) +} diff --git a/cubackend/cuserver.go b/cubackend/cuserver.go new file mode 100644 index 00000000..0e13ed21 --- /dev/null +++ b/cubackend/cuserver.go @@ -0,0 +1,359 @@ +// 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. + +//go:build linux && amd64 && cgo + +package cubackend + +// Package backend implements the cuObject-accelerated storage backend. +// CuServer embeds the versitygw backend and overrides PutObject and +// GetObject to use RDMA transfers when the request contains a cuObject +// RDMA descriptor. All other S3 operations delegate directly to backend. + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + + "github.com/aws/aws-sdk-go-v2/service/s3" + + "github.com/versity/versitygw/backend" + "github.com/versity/versitygw/cumiddleware" + "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/rdma" + "github.com/versity/versitygw/rdma/bufferpool" + "github.com/versity/versitygw/s3err" + "github.com/versity/versitygw/s3response" +) + +// CuServer is a versitygw Backend that embeds another backend and overrides +// PutObject/GetObject for RDMA-accelerated transfers via cuObjServer. +// All other S3 operations (list, head, delete, multipart, etc.) pass +// through to the embedded backend unchanged. +type CuServer struct { + backend.Backend + rdmaSrv *rdma.Server + pool *bufferpool.Pool +} + +// New creates a CuServer backend. It creates a cuObjServer instance, +// starts the RDMA session, and pre-allocates a pool of RDMA-registered +// host buffers. +func New(opts CuServerOpts, be backend.Backend) (*CuServer, error) { + rdmaSrv, err := rdma.NewServer(opts.RDMAIP, opts.RDMAPort, opts.RDMATunables) + if err != nil { + be.Shutdown() + return nil, fmt.Errorf("cuserver: rdma server: %w", err) + } + + // StartSession is a no-op when the library manages session start + // internally (e.g. libcuobjserver v1.2.0 calls startRDMASession from + // the cuObjServer constructor). It is kept here for forward compatibility + // with library versions that require an explicit call. + if err := rdmaSrv.StartSession(); err != nil { + rdmaSrv.Close() + be.Shutdown() + return nil, fmt.Errorf("cuserver: rdma session: %w", err) + } + + bufSize := opts.PoolBufSize + if bufSize <= 0 { + bufSize = rdma.MaxTransferSize + } + bufCount := opts.PoolBufCount + if bufCount <= 0 { + bufCount = 4 + } + + pool, err := bufferpool.NewPool(rdmaSrv, bufSize, bufCount) + if err != nil { + rdmaSrv.Close() + be.Shutdown() + return nil, fmt.Errorf("cuserver: buffer pool: %w", err) + } + + return &CuServer{ + Backend: be, + rdmaSrv: rdmaSrv, + pool: pool, + }, nil +} + +// String returns a human-readable identifier for the backend. +func (*CuServer) String() string { + return "cuObject Server" +} + +// Shutdown closes the buffer pool and RDMA server, then shuts down the +// embedded backend. +func (cs *CuServer) Shutdown() { + cs.pool.Close() + cs.rdmaSrv.Close() + cs.Backend.Shutdown() +} + +// PutObject handles S3 PUT requests. If the request context contains a +// cuObject RDMA descriptor, data is transferred via RDMA READ from the +// client's GPU memory into a local buffer, then written to the backend +// Without an RDMA descriptor, delegates directly to backend. +func (cs *CuServer) PutObject(ctx context.Context, po s3response.PutObjectInput) (s3response.PutObjectOutput, error) { + descr, ok := cumiddleware.GetRDMADescriptor(ctx) + if !ok { + debuglogger.Logf("doing normal put") + // Normal S3 path — no RDMA descriptor present + return cs.Backend.PutObject(ctx, po) + } + + debuglogger.Logf("doing RDMA put") + // RDMA-accelerated path + size, ok := cumiddleware.GetRDMASize(ctx) + if !ok || size <= 0 { + // Size comes from the legacy 3-header scheme or, for the combined + // token scheme, the standard Content-Length header — neither was usable. + return s3response.PutObjectOutput{}, fmt.Errorf("cuserver: RDMA PUT requires a positive size via the %s header or a standard Content-Length header", cumiddleware.HeaderRDMASize) + } + if size > int64(rdma.MaxTransferSize) { + return s3response.PutObjectOutput{}, fmt.Errorf("cuserver: object size %d exceeds RDMA max %d", size, rdma.MaxTransferSize) + } + + remoteStart := cumiddleware.GetRDMARemoteStart(ctx) + debuglogger.Logf("RDMA PUT params: key=%q size=%d remoteStart=0x%x descr=%s", keyFromPtr(po.Key), size, remoteStart, descr) + + channelID, err := cs.rdmaSrv.AllocateChannel() + if err != nil { + return s3response.PutObjectOutput{}, fmt.Errorf("cuserver: alloc channel: %w", err) + } + defer cs.rdmaSrv.FreeChannel(channelID) + + buf, _, err := cs.pool.Acquire(ctx) + if err != nil { + return s3response.PutObjectOutput{}, fmt.Errorf("cuserver: acquire buffer: %w", err) + } + defer cs.pool.Release(buf) + + key := keyFromPtr(po.Key) + + // Stream RDMA chunks into a pipe so backend can begin writing before + // the full transfer is complete. + pr, pw := io.Pipe() + po.Body = pr + po.ContentLength = &size + producerErrCh := make(chan error, 1) + go func() { + defer close(producerErrCh) + + local := buf.Slice() + remaining := size + offset := int64(0) + + for remaining > 0 { + chunk := rdmaPutChunkSize(len(local), remaining) + if chunk <= 0 { + wrapped := fmt.Errorf("cuserver: invalid RDMA PUT buffer size %d", len(local)) + _ = pw.CloseWithError(wrapped) + producerErrCh <- wrapped + return + } + + // RDMA READ current chunk from client into the start of local buffer. + n, e := cs.rdmaSrv.HandlePut(key, buf, remoteStart+uint64(offset), chunk, descr, channelID) + if e == nil && n != chunk { + e = fmt.Errorf("short RDMA transfer: got %d bytes, want %d", n, chunk) + } + if e != nil { + wrapped := fmt.Errorf("cuserver: RDMA PUT %q chunk offset=%d size=%d: %w", key, offset, chunk, e) + _ = pw.CloseWithError(wrapped) + producerErrCh <- wrapped + return + } + + if _, e := pw.Write(local[:int(chunk)]); e != nil { + producerErrCh <- e + return + } + + offset += chunk + remaining -= chunk + } + + if e := pw.Close(); e != nil { + producerErrCh <- e + return + } + producerErrCh <- nil + }() + + out, err := cs.Backend.PutObject(ctx, po) + if err != nil { + _ = pr.Close() + if prodErr := <-producerErrCh; prodErr != nil { + return s3response.PutObjectOutput{}, fmt.Errorf("cuserver: PUT %q failed (backend: %v; rdma/pipe: %w)", key, err, prodErr) + } + return s3response.PutObjectOutput{}, err + } + + if prodErr := <-producerErrCh; prodErr != nil { + return s3response.PutObjectOutput{}, prodErr + } + + cumiddleware.SetRDMAReplyHeader(ctx, http.StatusOK, size) + return out, nil +} + +// UploadPart handles S3 multipart upload part requests. RDMA offload is not +// supported here: object data would travel out-of-band via RDMA, but the +// embedded backend's UploadPart still expects the part in the HTTP body. +// Decline the descriptor so the client retries over the normal HTTP path +// instead of silently uploading a wrong/empty part. +func (cs *CuServer) UploadPart(ctx context.Context, input *s3.UploadPartInput) (*s3.UploadPartOutput, error) { + if _, ok := cumiddleware.GetRDMADescriptor(ctx); ok { + return nil, s3err.GetAPIError(s3err.ErrNotImplemented) + } + return cs.Backend.UploadPart(ctx, input) +} + +// GetObject handles S3 GET requests. If the request context contains a +// cuObject RDMA descriptor, the object is read from the backend +// into an RDMA buffer, then transferred via RDMA WRITE to the client's +// GPU memory. The HTTP response contains metadata only (no body). +// Without an RDMA descriptor, delegates directly to backend. +func (cs *CuServer) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.GetObjectOutput, error) { + descr, ok := cumiddleware.GetRDMADescriptor(ctx) + if !ok { + debuglogger.Logf("doing normal get") + // Normal S3 path + return cs.Backend.GetObject(ctx, input) + } + + debuglogger.Logf("doing RDMA get") + + // First, get the object from backend (opens the file, returns metadata + Body) + res, err := cs.Backend.GetObject(ctx, input) + if err != nil { + return nil, err + } + + // Determine the actual content size + var size int64 + if res.ContentLength != nil { + size = *res.ContentLength + } + if size <= 0 { + // No content to transfer — return metadata only + return res, nil + } + if size > int64(rdma.MaxTransferSize) { + if res.Body != nil { + res.Body.Close() + } + return nil, fmt.Errorf("cuserver: object size %d exceeds RDMA max %d", size, rdma.MaxTransferSize) + } + if capSize, ok := cumiddleware.GetRDMASize(ctx); ok { + if capSize <= 0 { + if res.Body != nil { + res.Body.Close() + } + return nil, fmt.Errorf("cuserver: RDMA GET requires a positive client capacity") + } + if size > capSize { + if res.Body != nil { + res.Body.Close() + } + return nil, fmt.Errorf("cuserver: object/range size %d exceeds RDMA client capacity %d", size, capSize) + } + } + + remoteStart := cumiddleware.GetRDMARemoteStart(ctx) + + channelID, err := cs.rdmaSrv.AllocateChannel() + if err != nil { + res.Body.Close() + return nil, fmt.Errorf("cuserver: alloc channel: %w", err) + } + defer cs.rdmaSrv.FreeChannel(channelID) + + buf, slice, err := cs.pool.Acquire(ctx) + if err != nil { + res.Body.Close() + return nil, fmt.Errorf("cuserver: acquire buffer: %w", err) + } + defer cs.pool.Release(buf) + defer res.Body.Close() + + if len(slice) == 0 { + return nil, fmt.Errorf("cuserver: invalid RDMA GET buffer size %d", len(slice)) + } + + key := keyFromPtr(input.Key) + debuglogger.Logf("RDMA GET params: key=%q size=%d remoteStart=0x%x descr=%s", key, size, remoteStart, descr) + + remaining := size + offset := int64(0) + for remaining > 0 { + chunk := rdmaPutChunkSize(len(slice), remaining) + if chunk <= 0 { + return nil, fmt.Errorf("cuserver: invalid RDMA GET buffer size %d", len(slice)) + } + + if _, err = io.ReadFull(res.Body, slice[:int(chunk)]); err != nil { + return nil, fmt.Errorf("cuserver: read object %q chunk offset=%d size=%d into RDMA buffer: %w", key, offset, chunk, err) + } + + n, err := cs.rdmaSrv.HandleGet(key, buf, remoteStart+uint64(offset), chunk, descr, channelID) + if err == nil && n != chunk { + err = fmt.Errorf("short RDMA transfer: got %d bytes, want %d", n, chunk) + } + if err != nil { + return nil, fmt.Errorf("cuserver: RDMA GET %q chunk offset=%d size=%d: %w", key, offset, chunk, err) + } + + offset += chunk + remaining -= chunk + } + + // Return metadata only — the data was already sent via RDMA. + // Set Body to empty and clear ContentLength so the HTTP response + // carries only headers. + res.Body = io.NopCloser(bytes.NewReader(nil)) + zero := int64(0) + res.ContentLength = &zero + + cumiddleware.SetRDMAReplyHeader(ctx, http.StatusOK, size) + return res, nil +} + +func keyFromPtr(p *string) string { + if p == nil { + return "" + } + return *p +} + +// Ensure CuServer satisfies the Backend interface at compile time. +var _ backend.Backend = (*CuServer)(nil) + +func rdmaPutChunkSize(bufSize int, remaining int64) int64 { + if bufSize <= 0 || remaining <= 0 { + return 0 + } + + chunkSize := int64(bufSize) + if remaining < chunkSize { + return remaining + } + + return chunkSize +} diff --git a/cubackend/cuserver_common.go b/cubackend/cuserver_common.go new file mode 100644 index 00000000..68362620 --- /dev/null +++ b/cubackend/cuserver_common.go @@ -0,0 +1,36 @@ +// 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 cubackend + +import ( + "github.com/versity/versitygw/rdma" +) + +// CuServerOpts configures the CuServer backend. +type CuServerOpts struct { + // RDMAIP is the server IP address for the RDMA interface. + RDMAIP string + // RDMAPort is the server port for the RDMA interface. + RDMAPort uint16 + + // Pool configuration + PoolBufSize int // Size of each RDMA buffer (default: 1 GiB) + PoolBufCount int // Number of RDMA buffers to pre-allocate (default: 4) + + // RDMATunables configures low-level RDMA connection parameters applied + // before the session starts. If nil, cuObjServer library defaults are + // used. Use rdma.DefaultRDMATunables() as a starting point. + RDMATunables *rdma.RDMATunables +} diff --git a/cubackend/cuserver_stub.go b/cubackend/cuserver_stub.go new file mode 100644 index 00000000..2964917b --- /dev/null +++ b/cubackend/cuserver_stub.go @@ -0,0 +1,36 @@ +// 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. + +//go:build !(linux && amd64 && cgo) + +package cubackend + +// Package backend provides the cuObject-accelerated storage backend. +// This file is a stub for platforms without RDMA support. + +import ( + "fmt" + + "github.com/versity/versitygw/backend" +) + +// CuServer is a non-functional stub on platforms without RDMA support. +type CuServer struct { + backend.BackendUnsupported +} + +// New always returns an unsupported-platform error on this build. +func New(opts CuServerOpts, be backend.Backend) (*CuServer, error) { + return nil, fmt.Errorf("cuserver: RDMA backend not supported on this platform") +} diff --git a/cumiddleware/cuobj.go b/cumiddleware/cuobj.go new file mode 100644 index 00000000..d0a0e4e6 --- /dev/null +++ b/cumiddleware/cuobj.go @@ -0,0 +1,245 @@ +// 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 cumiddleware + +// Package cumiddleware provides Fiber middleware for cuObject RDMA descriptor +// extraction. The middleware reads cuObject-specific HTTP headers and stores +// them as fasthttp user-values so the backend can detect RDMA-accelerated +// requests. +// +// Important: versitygw passes ctx.RequestCtx() (*fasthttp.RequestCtx) to the +// backend, not the fiber.Ctx or any context.WithValue wrapper. Values must +// therefore be stored via RequestCtx.SetUserValue (string key) so that +// context.Context.Value(stringKey) retrieves them correctly. + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/gofiber/fiber/v3" + "github.com/valyala/fasthttp" +) + +// String keys used for fasthttp user-value storage. +// Must be string constants so *fasthttp.RequestCtx.Value(key) finds them. +const ( + localKeyRDMADescr = "cuobj.rdma.descr" + localKeyRDMASize = "cuobj.rdma.size" + localKeyRDMARemoteStart = "cuobj.rdma.remote_start" +) + +// Header names used by the cuObject client to pass RDMA descriptor info. +// +// HeaderRDMADescr/HeaderRDMASize/HeaderRDMARemoteAddr are a legacy 3-header +// scheme used only by this repo's own test tools (cmd/cuobjtest, +// cmd/rdmatest). The real cuObject/minio-cpp SDK does not send these; it +// sends a single combined HeaderRDMAToken instead. Both schemes are +// supported so existing test tooling keeps working. +const ( + HeaderRDMADescr = "X-CuObj-RDMA-Descr" + HeaderRDMASize = "X-CuObj-Content-Length" + HeaderRDMARemoteAddr = "X-CuObj-Remote-Buf-Start" + + // HeaderRDMAToken is the single combined header sent by the real + // cuObject client SDK (e.g. minio-cpp). Its value is a colon-delimited + // token per the cuObj RDMA descriptor protocol. The whole token is + // passed through verbatim to the RDMA backend as the descriptor. + // + // This is the canonical definition of the wire format; the encoder in + // cuwrapper/rdma_host_client_wrapper.cpp (build_token) must stay in + // sync with it. Fields, in order, colon-delimited, lowercase hex: + // + // # | Field | Type | Width + // --|-----------------------------------------|----------|----------- + // 1 | Remote base address (GPUMEM/SYSMEM) | uint64 | 16 chars + // 2 | Max size of buffer region from base addr | uint32 | 8 chars + // 3 | Remote key (rkey) | uint32 | 8 chars + // 4 | LID of the client NIC | uint16 | 4 chars + // 5 | DCTN | uint32 | 6 chars + // 6 | GID present (1|0) | bool | 1 char + // 7 | GID of client NIC | 16 bytes | 32 chars + // + // Example: "0102030405060708:01020304:01020304:0102:010203:1:0102030405060708090a0b0c0d0e0f10" + HeaderRDMAToken = "X-Amz-Rdma-Token" + + // HeaderRDMAReply is the response header sent after a successful + // RDMA-offloaded PUT/GET, per NVIDIA's documented cuObject workflow: + // "If the transfer is successfully offloaded to RDMA, the proxy responds + // with x-amz-rdma-reply." This implementation carries a numeric RDMA + // status code (e.g. HTTP-style 200/204/206 success classes) and is set + // only after the RDMA operation has actually succeeded. + HeaderRDMAReply = "X-Amz-Rdma-Reply" + + // HeaderRDMABytesTransferred is the statistics header documented + // alongside HeaderRDMAToken/HeaderRDMAReply ("x-amz-rdma-bytes-transferred + // (for statistics)"). This header carries the numeric transferred-byte + // count and is set together with HeaderRDMAReply — see SetRDMAReplyHeader. + HeaderRDMABytesTransferred = "X-Amz-Rdma-Bytes-Transferred" +) + +// CuObjMiddleware extracts cuObject RDMA headers and stores them in +// the fasthttp request context so the backend can retrieve them via the +// GetRDMA* helper functions. +// +// If neither the legacy descriptor header nor the combined RDMA token +// header is present, the request passes through unchanged — the backend +// will use the normal (non-RDMA) code path. +// +// If a descriptor is present but required fields are malformed, a 400 Bad +// Request is returned immediately. +func CuObjMiddleware(ctx fiber.Ctx) error { + descr := ctx.Get(HeaderRDMADescr) + token := ctx.Get(HeaderRDMAToken) + if descr == "" && token == "" { + return ctx.Next() + } + + // Store directly on the underlying fasthttp RequestCtx so values survive + // the ctx.RequestCtx() call the versitygw controller uses to invoke the backend. + rctx := ctx.RequestCtx() + + if descr != "" { + // Legacy 3-header scheme. + rctx.SetUserValue(localKeyRDMADescr, descr) + + if sizeStr := ctx.Get(HeaderRDMASize); sizeStr != "" { + size, err := strconv.ParseInt(sizeStr, 10, 64) + if err != nil || size <= 0 { + return fiber.NewError(fiber.StatusBadRequest, + HeaderRDMASize+": must be a positive integer") + } + rctx.SetUserValue(localKeyRDMASize, size) + } + + if addrStr := ctx.Get(HeaderRDMARemoteAddr); addrStr != "" { + addr, err := strconv.ParseUint(addrStr, 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, + HeaderRDMARemoteAddr+": must be a non-negative integer") + } + rctx.SetUserValue(localKeyRDMARemoteStart, addr) + } + + return ctx.Next() + } + + // Combined token scheme (real cuObject client SDK). The descriptor + // passed to the RDMA backend is the raw token string; the remote base + // address is parsed from the token's first field, and the transfer size + // normally comes from the standard Content-Length header (RDMA replaces + // only the HTTP body, not the usual Content-Length semantics). A genuine + // RDMA control request can legitimately carry no HTTP body at all, in + // which case Content-Length is 0/absent; fall back to the token's own + // registered-buffer-size field (already part of the documented wire + // format) rather than leaving the backend with no usable size. + rctx.SetUserValue(localKeyRDMADescr, token) + + remoteStart, err := parseRDMATokenBaseAddr(token) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, HeaderRDMAToken+": "+err.Error()) + } + rctx.SetUserValue(localKeyRDMARemoteStart, remoteStart) + + if size := rctx.Request.Header.ContentLength(); size > 0 { + rctx.SetUserValue(localKeyRDMASize, int64(size)) + } else if bufSize, err := parseRDMATokenBufferSize(token); err == nil && bufSize > 0 { + rctx.SetUserValue(localKeyRDMASize, int64(bufSize)) + } + + return ctx.Next() +} + +// parseRDMATokenBaseAddr extracts the remote base address — the first +// colon-delimited field, a hex-encoded uint64 — from a cuObj RDMA token. +func parseRDMATokenBaseAddr(token string) (uint64, error) { + i := 0 + for i < len(token) && token[i] != ':' { + i++ + } + if i == 0 || i == len(token) { + return 0, errors.New("malformed RDMA token: missing base address field") + } + addr, err := strconv.ParseUint(token[:i], 16, 64) + if err != nil { + return 0, fmt.Errorf("malformed RDMA token base address: %w", err) + } + return addr, nil +} + +// parseRDMATokenBufferSize extracts the registered buffer size — the second +// colon-delimited field, a hex-encoded uint32 — from a cuObj RDMA token. Used +// as a size fallback when the request has no positive Content-Length. +func parseRDMATokenBufferSize(token string) (uint32, error) { + fields := strings.SplitN(token, ":", 3) + if len(fields) < 2 || fields[1] == "" { + return 0, errors.New("malformed RDMA token: missing buffer size field") + } + size, err := strconv.ParseUint(fields[1], 16, 32) + if err != nil { + return 0, fmt.Errorf("malformed RDMA token buffer size: %w", err) + } + return uint32(size), nil +} + +// GetRDMADescriptor retrieves the RDMA descriptor from the context. +// Returns ("", false) if the request is not an RDMA-accelerated request. +func GetRDMADescriptor(ctx context.Context) (string, bool) { + v, ok := ctx.Value(localKeyRDMADescr).(string) + return v, ok && v != "" +} + +// GetRDMASize retrieves the RDMA content length from the context. +func GetRDMASize(ctx context.Context) (int64, bool) { + v, ok := ctx.Value(localKeyRDMASize).(int64) + return v, ok +} + +// GetRDMARemoteStart retrieves the remote buffer start address from the context. +// Defaults to 0 if not set. +func GetRDMARemoteStart(ctx context.Context) uint64 { + v, _ := ctx.Value(localKeyRDMARemoteStart).(uint64) + return v +} + +// SetRDMAReplyHeader sets the HeaderRDMAReply (status code) and +// HeaderRDMABytesTransferred (byte count) response headers, signaling to the +// client that the transfer was completed via RDMA rather than the HTTP body. +// Call only after the RDMA transfer has actually succeeded. ctx must be the +// same *fasthttp.RequestCtx handed to the backend by versitygw; it is a no-op +// otherwise (e.g. in unit tests without an HTTP layer). +func SetRDMAReplyHeader(ctx context.Context, rdmaStatus int, transferredBytes int64) { + rctx, ok := ctx.(*fasthttp.RequestCtx) + if !ok { + return + } + rctx.Response.Header.Set(HeaderRDMAReply, strconv.Itoa(rdmaStatus)) + rctx.Response.Header.Set(HeaderRDMABytesTransferred, strconv.FormatInt(transferredBytes, 10)) +} + +// InjectRDMAContext returns a copy of ctx with RDMA descriptor values set. +// This bypasses the Fiber middleware and is intended for testing and direct +// backend invocation without an HTTP layer. +func InjectRDMAContext(ctx context.Context, descr string, size int64, remoteStart uint64) context.Context { + //lint:ignore SA1029 string keys required for fasthttp RequestCtx cross-package value lookup + ctx = context.WithValue(ctx, localKeyRDMADescr, descr) + //lint:ignore SA1029 string keys required for fasthttp RequestCtx cross-package value lookup + ctx = context.WithValue(ctx, localKeyRDMASize, size) + //lint:ignore SA1029 string keys required for fasthttp RequestCtx cross-package value lookup + ctx = context.WithValue(ctx, localKeyRDMARemoteStart, remoteStart) + return ctx +} diff --git a/cumiddleware/cuobj_test.go b/cumiddleware/cuobj_test.go new file mode 100644 index 00000000..aeda9971 --- /dev/null +++ b/cumiddleware/cuobj_test.go @@ -0,0 +1,182 @@ +// 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 cumiddleware + +// Protocol-level tests for the cuObject RDMA header parsing middleware. +// These exercise the wire-format contract without requiring any RDMA +// hardware: the middleware only ever inspects HTTP headers and stashes +// parsed values on the request context. + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" +) + +// tokenWithBufSize constructs a valid cuObj RDMA token per the +// HeaderRDMAToken wire format documented next to its definition. +func tokenWithBufSize(baseAddr uint64, bufSize uint32) string { + return hex64(baseAddr) + ":" + hex32(bufSize) + ":01020304:0102:010203:1:0102030405060708090a0b0c0d0e0f10" +} + +func hex64(v uint64) string { + s := strconv.FormatUint(v, 16) + for len(s) < 16 { + s = "0" + s + } + return s +} + +func hex32(v uint32) string { + s := strconv.FormatUint(uint64(v), 16) + for len(s) < 8 { + s = "0" + s + } + return s +} + +func newTestApp(t *testing.T) (*fiber.App, chan bool) { + t.Helper() + reached := make(chan bool, 1) + app := fiber.New() + app.Use("*", CuObjMiddleware) + app.Post("/", func(ctx fiber.Ctx) error { + reached <- true + return ctx.SendStatus(http.StatusOK) + }) + return app, reached +} + +func TestCuObjMiddlewareNoHeadersPassesThrough(t *testing.T) { + app, _ := newTestApp(t) + req := httptest.NewRequest(http.MethodPost, "/", nil) + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestCuObjMiddlewareLegacyHeaders(t *testing.T) { + app := fiber.New() + app.Use("*", CuObjMiddleware) + app.Post("/", func(ctx fiber.Ctx) error { + rctx := ctx.RequestCtx() + descr, ok := GetRDMADescriptor(rctx) + assert.True(t, ok) + assert.Equal(t, "deadbeef", descr) + size, ok := GetRDMASize(rctx) + assert.True(t, ok) + assert.Equal(t, int64(4096), size) + assert.Equal(t, uint64(4660), GetRDMARemoteStart(rctx)) + return ctx.SendStatus(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set(HeaderRDMADescr, "deadbeef") + req.Header.Set(HeaderRDMASize, "4096") + req.Header.Set(HeaderRDMARemoteAddr, "4660") + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestCuObjMiddlewareTokenWithContentLength(t *testing.T) { + app := fiber.New() + app.Use("*", CuObjMiddleware) + app.Post("/", func(ctx fiber.Ctx) error { + rctx := ctx.RequestCtx() + descr, ok := GetRDMADescriptor(rctx) + assert.True(t, ok) + token := tokenWithBufSize(0x1122334455667788, 0x2000) + assert.Equal(t, token, descr) + size, ok := GetRDMASize(rctx) + assert.True(t, ok) + assert.Equal(t, int64(1234), size) // from Content-Length, not the token buffer size + assert.Equal(t, uint64(0x1122334455667788), GetRDMARemoteStart(rctx)) + return ctx.SendStatus(http.StatusOK) + }) + + token := tokenWithBufSize(0x1122334455667788, 0x2000) + body := make([]byte, 1234) + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + req.Header.Set(HeaderRDMAToken, token) + req.ContentLength = int64(len(body)) + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +// TestCuObjMiddlewareTokenZeroBodyFallsBackToTokenSize covers a genuine RDMA +// control request that carries no HTTP body at all (Content-Length 0/unset): +// the size must fall back to the token's own registered-buffer-size field +// instead of leaving the backend with no usable size. +func TestCuObjMiddlewareTokenZeroBodyFallsBackToTokenSize(t *testing.T) { + app := fiber.New() + app.Use("*", CuObjMiddleware) + app.Post("/", func(ctx fiber.Ctx) error { + rctx := ctx.RequestCtx() + size, ok := GetRDMASize(rctx) + assert.True(t, ok) + assert.Equal(t, int64(0x2000), size) + return ctx.SendStatus(http.StatusOK) + }) + + token := tokenWithBufSize(0x1122334455667788, 0x2000) + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set(HeaderRDMAToken, token) + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestCuObjMiddlewareMalformedTokenRejected(t *testing.T) { + app, reached := newTestApp(t) + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set(HeaderRDMAToken, "not-a-valid-token") + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + select { + case <-reached: + t.Fatal("handler should not have been reached for a malformed token") + default: + } +} + +func TestSetRDMAReplyHeader(t *testing.T) { + app := fiber.New() + app.Post("/", func(ctx fiber.Ctx) error { + SetRDMAReplyHeader(ctx.RequestCtx(), http.StatusOK, 65536) + return ctx.SendStatus(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, "200", resp.Header.Get(HeaderRDMAReply)) + assert.Equal(t, "65536", resp.Header.Get(HeaderRDMABytesTransferred)) +} + +func TestSetRDMAReplyHeaderNoopForNonFasthttpContext(t *testing.T) { + // Must not panic when called with a plain context.Context, e.g. from a + // unit test that injects RDMA values via InjectRDMAContext without an + // HTTP layer. + ctx := InjectRDMAContext(t.Context(), "descr", 10, 0) + SetRDMAReplyHeader(ctx, http.StatusOK, 10) +} diff --git a/cuobjclient/limits.go b/cuobjclient/limits.go new file mode 100644 index 00000000..7aa63023 --- /dev/null +++ b/cuobjclient/limits.go @@ -0,0 +1,19 @@ +// 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 cuobjclient + +// MaxTransferSize is the largest transfer size supported by the cuObject +// client path in this package. +const MaxTransferSize = 1 << 30 diff --git a/cuobjclient/session_host_linux.go b/cuobjclient/session_host_linux.go new file mode 100644 index 00000000..151a2359 --- /dev/null +++ b/cuobjclient/session_host_linux.go @@ -0,0 +1,163 @@ +// 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. + +//go:build linux && amd64 && cgo && cuobjclient_host + +// This file implements the cuObjClient Session API on top of the host-memory +// RDMA client (package rdma/hostclient). It contains no CUDA/GPU dependency and +// is selected with the `cuobjclient_host` build tag for RDMA-capable hosts that +// have no GPU. +// +// The RDMA endpoint is configured from the environment so the NewSession(size) +// SDK signature stays identical to the GPU build: +// +// VGWRDMA_RDMA_DEV RDMA device name (default: first device, e.g. mlx5_0) +// VGWRDMA_RDMA_PORT HCA port number (default: 1) +// VGWRDMA_GID_INDEX RoCE GID index from `ibv_devinfo -v` +// (default: auto-select first non-link-local GID) +// VGWRDMA_DC_KEY Dynamic Connection key, decimal or 0x-hex +// (default: matches server DCKey 0xffeeddcc) +package cuobjclient + +import ( + "fmt" + "os" + "strconv" + + s3lib "github.com/aws/aws-sdk-go-v2/service/s3" + + "github.com/versity/versitygw/rdma/hostclient" +) + +// Session owns a host buffer registered for cuObject RDMA transfers via the +// host-memory RDMA client. Session methods are not safe for concurrent use. +type Session struct { + client *hostclient.Client + buf []byte + token string + size int + remoteStart uint64 +} + +// NewSession creates a host-memory RDMA session for a fixed transfer size. +func NewSession(size int) (*Session, error) { + if size <= 0 { + return nil, fmt.Errorf("invalid size %d", size) + } + if size > MaxTransferSize { + return nil, fmt.Errorf("invalid size %d: exceeds MaxTransferSize (%d)", size, MaxTransferSize) + } + + dev := os.Getenv("VGWRDMA_RDMA_DEV") + port := envUint8("VGWRDMA_RDMA_PORT", 1) + gidIndex := envInt("VGWRDMA_GID_INDEX", -1) + dcKey := envUint64("VGWRDMA_DC_KEY", hostclient.DefaultDCKey) + + client, err := hostclient.NewClient(dev, port, gidIndex, dcKey) + if err != nil { + return nil, err + } + + buf, err := client.Register(size) + if err != nil { + client.Close() + return nil, err + } + token, err := client.Token() + if err != nil { + client.Close() + return nil, err + } + + return &Session{ + client: client, + buf: buf, + token: token, + size: size, + remoteStart: client.BufferAddr(), + }, nil +} + +// Close releases the registered host buffer and closes the RDMA client. +// After Close returns, the Session must not be used. +func (s *Session) Close() { + if s.client == nil { + return + } + s.client.Close() + s.client = nil + s.buf = nil +} + +// Upload copies src into the registered host buffer and performs a PUT. The +// gateway RDMA-reads the buffer contents during the request. +func (s *Session) Upload(base *s3lib.Client, bucket, key string, src []byte) error { + if len(src) != s.size { + return fmt.Errorf("upload size mismatch: got %d bytes, want %d", len(src), s.size) + } + copy(s.buf, src) + return doPut(base, bucket, key, int64(s.size), s.token, s.remoteStart) +} + +// Download performs a GET; the gateway RDMA-writes into the registered host +// buffer, then the bytes are copied into dst. +func (s *Session) Download(base *s3lib.Client, bucket, key string, dst []byte) error { + if len(dst) != s.size { + return fmt.Errorf("download size mismatch: got %d bytes, want %d", len(dst), s.size) + } + for i := range s.buf { + s.buf[i] = 0 + } + if err := doGet(base, bucket, key, int64(s.size), s.token, s.remoteStart); err != nil { + return err + } + copy(dst, s.buf) + return nil +} + +func envUint8(name string, def uint8) uint8 { + v := os.Getenv(name) + if v == "" { + return def + } + n, err := strconv.ParseUint(v, 10, 8) + if err != nil { + return def + } + return uint8(n) +} + +func envInt(name string, def int) int { + v := os.Getenv(name) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + return def + } + return n +} + +func envUint64(name string, def uint64) uint64 { + v := os.Getenv(name) + if v == "" { + return def + } + n, err := strconv.ParseUint(v, 0, 64) + if err != nil { + return def + } + return n +} diff --git a/cuobjclient/session_linux.go b/cuobjclient/session_linux.go new file mode 100644 index 00000000..6b8374fb --- /dev/null +++ b/cuobjclient/session_linux.go @@ -0,0 +1,210 @@ +// 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. + +//go:build linux && amd64 && cgo && !cuobjclient_host + +package cuobjclient + +/* +#cgo CFLAGS: -I/usr/include -I${SRCDIR}/../cuwrapper +#cgo LDFLAGS: -L${SRCDIR}/../rdma -l:libcuobjclientwrapper.a -L/usr/lib64 -lcuobjclient -lcudart -lstdc++ -ldl +#include +#include "cuobjclient_wrapper.h" +*/ +import "C" + +import ( + "fmt" + "unsafe" + + s3lib "github.com/aws/aws-sdk-go-v2/service/s3" +) + +const ( + cuObjOpGet = 0 + cuObjOpPut = 1 +) + +// Session owns a CUDA buffer registered for cuObject RDMA token exchange. +type Session struct { + ctx *C.cuobj_client_ctx_t + gpuBuf unsafe.Pointer + size int + remoteStart uint64 +} + +// NewSession creates a GPU-backed cuObject session for a fixed transfer size. +// The returned session allocates CUDA memory, registers the descriptor with +// cuobjclient, and prepares RDMA token generation for Upload and Download. +// size must be in the range [1, MaxTransferSize]. +// A Session is reusable for multiple transfers as long as each Upload or +// Download uses buffers with length equal to the size passed to NewSession. +// Callers should keep a session open for repeated sequential operations and +// call Close once the session is no longer needed. +// Session methods are not safe for concurrent use. Do not call Upload, +// Download, or Close from multiple goroutines at the same time on the same +// Session value. +// Call Close when finished to release all resources. +func NewSession(size int) (*Session, error) { + if size <= 0 { + return nil, fmt.Errorf("invalid size %d", size) + } + if size > MaxTransferSize { + return nil, fmt.Errorf("invalid size %d: exceeds MaxTransferSize (%d)", size, MaxTransferSize) + } + + ctx := C.cuobj_client_create() + if ctx == nil { + return nil, fmt.Errorf("cuobj_client_create failed") + } + + s := &Session{ctx: ctx, size: size} + buf := C.cuobj_client_cuda_malloc(C.size_t(size)) + if buf == nil { + s.Close() + return nil, fmt.Errorf("cudaMalloc(%d) failed", size) + } + s.gpuBuf = buf + + if err := s.register(); err != nil { + s.Close() + return nil, err + } + + s.remoteStart = uint64(C.cuobj_client_ptr_to_u64(s.gpuBuf)) + return s, nil +} + +// Close releases the GPU buffer and destroys the cuObject client context. +// After Close returns, the Session must not be used. +func (s *Session) Close() { + if s.ctx == nil { + return + } + if s.gpuBuf != nil { + _ = s.unregister() + _ = s.cudaFree() + s.gpuBuf = nil + } + C.cuobj_client_destroy(s.ctx) + s.ctx = nil +} + +// Upload copies src into the session GPU buffer and performs a PUT operation +// using cuObject RDMA headers. +// src length must exactly match the size passed to NewSession. +func (s *Session) Upload(base *s3lib.Client, bucket, key string, src []byte) error { + if len(src) != s.size { + return fmt.Errorf("upload size mismatch: got %d bytes, want %d", len(src), s.size) + } + if err := s.copyH2D(src); err != nil { + return err + } + token, err := s.getToken(cuObjOpPut) + if err != nil { + return err + } + defer s.putToken(token) + return doPut(base, bucket, key, int64(s.size), C.GoString(token), s.remoteStart) +} + +// Download performs a GET operation into the session GPU buffer and copies the +// resulting bytes back into dst. +// dst length must exactly match the size passed to NewSession. +func (s *Session) Download(base *s3lib.Client, bucket, key string, dst []byte) error { + if len(dst) != s.size { + return fmt.Errorf("download size mismatch: got %d bytes, want %d", len(dst), s.size) + } + if err := s.memset(0); err != nil { + return err + } + token, err := s.getToken(cuObjOpGet) + if err != nil { + return err + } + defer s.putToken(token) + if err := doGet(base, bucket, key, int64(s.size), C.GoString(token), s.remoteStart); err != nil { + return err + } + return s.copyD2H(dst) +} + +func (s *Session) register() error { + rc := C.cuobj_client_register_descriptor(s.ctx, s.gpuBuf, C.size_t(s.size)) + if rc != 0 { + return fmt.Errorf("cuMemObjGetDescriptor failed (rc=%d)", int(rc)) + } + return nil +} + +func (s *Session) unregister() error { + rc := C.cuobj_client_unregister_descriptor(s.ctx, s.gpuBuf) + if rc != 0 { + return fmt.Errorf("cuMemObjPutDescriptor failed (rc=%d)", int(rc)) + } + return nil +} + +func (s *Session) cudaFree() error { + rc := C.cuobj_client_cuda_free(s.gpuBuf) + if rc != 0 { + return fmt.Errorf("cudaFree failed: %s", C.GoString(C.cuobj_client_cuda_error_string(rc))) + } + return nil +} + +func (s *Session) memset(val int) error { + rc := C.cuobj_client_cuda_memset(s.gpuBuf, C.int(val), C.size_t(s.size)) + if rc != 0 { + return fmt.Errorf("cudaMemset failed: %s", C.GoString(C.cuobj_client_cuda_error_string(rc))) + } + return nil +} + +func (s *Session) copyH2D(src []byte) error { + if len(src) == 0 { + return nil + } + rc := C.cuobj_client_cuda_memcpy_h2d(s.gpuBuf, unsafe.Pointer(&src[0]), C.size_t(len(src))) + if rc != 0 { + return fmt.Errorf("cudaMemcpy H2D failed: %s", C.GoString(C.cuobj_client_cuda_error_string(rc))) + } + return nil +} + +func (s *Session) copyD2H(dst []byte) error { + if len(dst) == 0 { + return nil + } + rc := C.cuobj_client_cuda_memcpy_d2h(unsafe.Pointer(&dst[0]), s.gpuBuf, C.size_t(len(dst))) + if rc != 0 { + return fmt.Errorf("cudaMemcpy D2H failed: %s", C.GoString(C.cuobj_client_cuda_error_string(rc))) + } + return nil +} + +func (s *Session) getToken(op int) (*C.char, error) { + t := C.cuobj_client_get_rdma_token(s.ctx, s.gpuBuf, C.size_t(s.size), C.size_t(0), C.int(op)) + if t == nil { + return nil, fmt.Errorf("cuMemObjGetRDMAToken failed") + } + return t, nil +} + +func (s *Session) putToken(token *C.char) { + if token == nil { + return + } + _ = C.cuobj_client_put_rdma_token(s.ctx, token) +} diff --git a/cuobjclient/session_stub.go b/cuobjclient/session_stub.go new file mode 100644 index 00000000..fb70b762 --- /dev/null +++ b/cuobjclient/session_stub.go @@ -0,0 +1,66 @@ +// 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. + +//go:build !(linux && amd64 && cgo) + +package cuobjclient + +import ( + "fmt" + + s3lib "github.com/aws/aws-sdk-go-v2/service/s3" +) + +// Session is a non-Linux stub so packages compile on unsupported platforms. +type Session struct{} + +// NewSession always returns an unsupported-platform error on this build. +// +// On supported platforms, sessions are intended to be reused for multiple +// sequential transfers and are not safe for concurrent use by multiple +// goroutines. +func NewSession(size int) (*Session, error) { + if size <= 0 { + return nil, fmt.Errorf("invalid size %d", size) + } + if size > MaxTransferSize { + return nil, fmt.Errorf("invalid size %d: exceeds MaxTransferSize (%d)", size, MaxTransferSize) + } + return nil, fmt.Errorf("cuobjclient: NewSession is only supported on linux/amd64 with cgo") +} + +// Close is a no-op in the unsupported-platform stub. +func (s *Session) Close() { + _ = s +} + +// Upload always returns an unsupported-platform error on this build. +func (s *Session) Upload(base *s3lib.Client, bucket, key string, src []byte) error { + _ = s + _ = base + _ = bucket + _ = key + _ = src + return fmt.Errorf("cuobjclient: Upload is only supported on linux/amd64 with cgo") +} + +// Download always returns an unsupported-platform error on this build. +func (s *Session) Download(base *s3lib.Client, bucket, key string, dst []byte) error { + _ = s + _ = base + _ = bucket + _ = key + _ = dst + return fmt.Errorf("cuobjclient: Download is only supported on linux/amd64 with cgo") +} diff --git a/cuobjclient/transport.go b/cuobjclient/transport.go new file mode 100644 index 00000000..7a9cfd04 --- /dev/null +++ b/cuobjclient/transport.go @@ -0,0 +1,164 @@ +// 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. + +//go:build linux && amd64 && cgo + +// This file holds the S3 request plumbing shared by the GPU (session_linux.go) +// and host-memory (session_host_linux.go) session implementations: both issue +// a zero-byte PUT/GET carrying the cuObject RDMA descriptor headers, and let +// the gateway perform the actual transfer via RDMA. +package cuobjclient + +import ( + "context" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + smithymiddleware "github.com/aws/smithy-go/middleware" + smithyhttp "github.com/aws/smithy-go/transport/http" + + s3lib "github.com/aws/aws-sdk-go-v2/service/s3" + + "github.com/versity/versitygw/cumiddleware" +) + +func doPut(base *s3lib.Client, bucket, key string, size int64, descr string, remoteStart uint64) error { + var replyStatus string + var transferredHeader string + c := withRDMAHeaders(base, descr, size, remoteStart, &replyStatus, &transferredHeader) + _, err := c.PutObject(context.Background(), &s3lib.PutObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + Body: strings.NewReader(""), + ContentLength: aws.Int64(0), + }) + if err != nil { + return err + } + if replyStatus == "" { + return fmt.Errorf("cuobjclient: gateway did not confirm RDMA offload (missing %s response header)", cumiddleware.HeaderRDMAReply) + } + rdmaStatus, err := strconv.Atoi(replyStatus) + if err != nil { + return fmt.Errorf("cuobjclient: invalid %s header %q: %w", cumiddleware.HeaderRDMAReply, replyStatus, err) + } + if rdmaStatus != http.StatusOK && rdmaStatus != http.StatusNoContent { + return fmt.Errorf("cuobjclient: RDMA offload not successful, %s=%d", cumiddleware.HeaderRDMAReply, rdmaStatus) + } + if transferredHeader == "" { + return fmt.Errorf("cuobjclient: missing %s response header", cumiddleware.HeaderRDMABytesTransferred) + } + transferred, err := strconv.ParseInt(transferredHeader, 10, 64) + if err != nil { + return fmt.Errorf("cuobjclient: invalid %s header %q: %w", cumiddleware.HeaderRDMABytesTransferred, transferredHeader, err) + } + if transferred != size { + return fmt.Errorf("cuobjclient: RDMA transferred %d bytes, want %d", transferred, size) + } + return nil +} + +func doGet(base *s3lib.Client, bucket, key string, size int64, descr string, remoteStart uint64) error { + var replyStatus string + var transferredHeader string + c := withRDMAHeaders(base, descr, size, remoteStart, &replyStatus, &transferredHeader) + out, err := c.GetObject(context.Background(), &s3lib.GetObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }) + if err != nil { + return err + } + defer out.Body.Close() + // The RDMA GET path deliberately reports ContentLength 0 on the HTTP + // response (the object bytes were already sent via RDMA, not the HTTP + // body), so the transfer must be confirmed via the gateway's RDMA reply + // header instead. A short/oversized transfer or a missing reply (offload + // silently not applied) is caught here rather than treated as success. + if replyStatus == "" { + return fmt.Errorf("cuobjclient: gateway did not confirm RDMA offload (missing %s response header)", cumiddleware.HeaderRDMAReply) + } + rdmaStatus, err := strconv.Atoi(replyStatus) + if err != nil { + return fmt.Errorf("cuobjclient: invalid %s header %q: %w", cumiddleware.HeaderRDMAReply, replyStatus, err) + } + if rdmaStatus != http.StatusOK && rdmaStatus != http.StatusNoContent && rdmaStatus != http.StatusPartialContent { + return fmt.Errorf("cuobjclient: RDMA offload not successful, %s=%d", cumiddleware.HeaderRDMAReply, rdmaStatus) + } + if transferredHeader == "" { + return fmt.Errorf("cuobjclient: missing %s response header", cumiddleware.HeaderRDMABytesTransferred) + } + transferred, err := strconv.ParseInt(transferredHeader, 10, 64) + if err != nil { + return fmt.Errorf("cuobjclient: invalid %s header %q: %w", cumiddleware.HeaderRDMABytesTransferred, transferredHeader, err) + } + if transferred != size { + return fmt.Errorf("cuobjclient: RDMA transferred %d bytes, want %d", transferred, size) + } + _, err = io.Copy(io.Discard, out.Body) + return err +} + +// withRDMAHeaders returns a client that adds the legacy RDMA descriptor, +// size, and remote-address headers to every request. The headers are added +// via a Build-step middleware — which runs before the Finalize step that +// signs the request — so SigV4 covers them in SignedHeaders; an +// intermediary can no longer alter the RDMA controls without invalidating +// the signature. The caller's HTTPClient (with its own TLS/proxy/timeout +// configuration) is left untouched. Automatic request/response checksum +// calculation is disabled for these control requests: the SDK would +// otherwise checksum the empty HTTP body instead of the actual RDMA payload. +// If replyStatus/transferred are non-nil, they are set to the response +// HeaderRDMAReply and HeaderRDMABytesTransferred values (empty if absent). +func withRDMAHeaders(base *s3lib.Client, descr string, size int64, remoteStart uint64, replyStatus, transferred *string) *s3lib.Client { + opts := base.Options() + opts.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired + opts.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired + opts.APIOptions = append(opts.APIOptions, func(stack *smithymiddleware.Stack) error { + if err := stack.Build.Add(smithymiddleware.BuildMiddlewareFunc("AddRDMAHeaders", + func(ctx context.Context, in smithymiddleware.BuildInput, next smithymiddleware.BuildHandler) ( + smithymiddleware.BuildOutput, smithymiddleware.Metadata, error) { + if req, ok := in.Request.(*smithyhttp.Request); ok { + req.Header.Set(cumiddleware.HeaderRDMADescr, descr) + req.Header.Set(cumiddleware.HeaderRDMASize, strconv.FormatInt(size, 10)) + req.Header.Set(cumiddleware.HeaderRDMARemoteAddr, strconv.FormatUint(remoteStart, 10)) + } + return next.HandleBuild(ctx, in) + }), smithymiddleware.Before); err != nil { + return err + } + if replyStatus == nil && transferred == nil { + return nil + } + return stack.Deserialize.Add(smithymiddleware.DeserializeMiddlewareFunc("CaptureRDMAReply", + func(ctx context.Context, in smithymiddleware.DeserializeInput, next smithymiddleware.DeserializeHandler) ( + smithymiddleware.DeserializeOutput, smithymiddleware.Metadata, error) { + out, metadata, err := next.HandleDeserialize(ctx, in) + if resp, ok := out.RawResponse.(*smithyhttp.Response); ok { + if replyStatus != nil { + *replyStatus = resp.Header.Get(cumiddleware.HeaderRDMAReply) + } + if transferred != nil { + *transferred = resp.Header.Get(cumiddleware.HeaderRDMABytesTransferred) + } + } + return out, metadata, err + }), smithymiddleware.After) + }) + return s3lib.New(opts) +} diff --git a/cuwrapper/cuobjclient_wrapper.cpp b/cuwrapper/cuobjclient_wrapper.cpp new file mode 100644 index 00000000..51f9060e --- /dev/null +++ b/cuwrapper/cuobjclient_wrapper.cpp @@ -0,0 +1,153 @@ +// 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. + +// C wrapper implementation for cuObjClient and CUDA runtime operations. + +#include "cuobjclient_wrapper.h" + +#include +#include +#include + +#include +#include + +struct cuobj_client_ctx { + CUObjOps_t ops; + cuObjClient *client; +}; + +static ssize_t noop_get(const void *, char *, size_t, loff_t, const cufileRDMAInfo_t *) { + return -EOPNOTSUPP; +} + +static ssize_t noop_put(const void *, const char *, size_t, loff_t, const cufileRDMAInfo_t *) { + return -EOPNOTSUPP; +} + +extern "C" { + +cuobj_client_ctx_t* cuobj_client_create(void) { + try { + auto *ctx = new cuobj_client_ctx_t(); + ctx->ops.get = noop_get; + ctx->ops.put = noop_put; + ctx->client = new cuObjClient(ctx->ops, CUOBJ_PROTO_RDMA_DC_V1); + return ctx; + } catch (...) { + return nullptr; + } +} + +void cuobj_client_destroy(cuobj_client_ctx_t *ctx) { + if (!ctx) { + return; + } + delete ctx->client; + delete ctx; +} + +void* cuobj_client_cuda_malloc(size_t size) { + void *ptr = nullptr; + cudaError_t rc = cudaMalloc(&ptr, size); + if (rc != cudaSuccess) { + return nullptr; + } + return ptr; +} + +int cuobj_client_cuda_free(void *ptr) { + cudaError_t rc = cudaFree(ptr); + return static_cast(rc); +} + +int cuobj_client_cuda_memset(void *ptr, int value, size_t size) { + cudaError_t rc = cudaMemset(ptr, value, size); + return static_cast(rc); +} + +int cuobj_client_cuda_memcpy_h2d(void *dst_dev, const void *src_host, size_t size) { + cudaError_t rc = cudaMemcpy(dst_dev, src_host, size, cudaMemcpyHostToDevice); + return static_cast(rc); +} + +int cuobj_client_cuda_memcpy_d2h(void *dst_host, const void *src_dev, size_t size) { + cudaError_t rc = cudaMemcpy(dst_host, src_dev, size, cudaMemcpyDeviceToHost); + return static_cast(rc); +} + +const char* cuobj_client_cuda_error_string(int cuda_err) { + return cudaGetErrorString(static_cast(cuda_err)); +} + +int cuobj_client_register_descriptor(cuobj_client_ctx_t *ctx, void *ptr, size_t size) { + if (!ctx || !ctx->client || !ptr || size == 0) { + return 1; + } + cuObjErr_t rc = ctx->client->cuMemObjGetDescriptor(ptr, size); + return static_cast(rc); +} + +int cuobj_client_unregister_descriptor(cuobj_client_ctx_t *ctx, void *ptr) { + if (!ctx || !ctx->client || !ptr) { + return 1; + } + cuObjErr_t rc = ctx->client->cuMemObjPutDescriptor(ptr); + // Synchronize the device to ensure the hardware-level RDMA deregistration + // (memory unpinning, hardware lock release) completes before this call + // returns. Without this, a subsequent process that immediately calls + // cuMemObjGetDescriptor on a new allocation can race with the driver's + // asynchronous cleanup and receive a hardware rejection. + cudaDeviceSynchronize(); + return static_cast(rc); +} + +char* cuobj_client_get_rdma_token(cuobj_client_ctx_t *ctx, + void *ptr, + size_t size, + size_t buffer_offset, + int operation) { + if (!ctx || !ctx->client || !ptr || size == 0) { + return nullptr; + } + cuObjOpType_t op; + if (operation == CUOBJCLIENT_OP_GET) { + op = CUOBJ_GET; + } else if (operation == CUOBJCLIENT_OP_PUT) { + op = CUOBJ_PUT; + } else { + return nullptr; + } + + char *token = nullptr; + cuObjErr_t rc = ctx->client->cuMemObjGetRDMAToken(ptr, size, buffer_offset, op, &token); + if (rc != CU_OBJ_SUCCESS) { + return nullptr; + } + return token; +} + +int cuobj_client_put_rdma_token(cuobj_client_ctx_t *ctx, char *token) { + if (!ctx || !ctx->client || !token) { + return 1; + } + cuObjErr_t rc = ctx->client->cuMemObjPutRDMAToken(token); + return static_cast(rc); +} + +uint64_t cuobj_client_ptr_to_u64(void *ptr) { + return static_cast(reinterpret_cast(ptr)); +} + +} // extern "C" diff --git a/cuwrapper/cuobjclient_wrapper.h b/cuwrapper/cuobjclient_wrapper.h new file mode 100644 index 00000000..f99580d6 --- /dev/null +++ b/cuwrapper/cuobjclient_wrapper.h @@ -0,0 +1,67 @@ +// 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. + +// C wrapper for cuObjClient + CUDA runtime APIs. +// Exposes a C ABI suitable for CGO. + +#ifndef CUOBJCLIENT_WRAPPER_H +#define CUOBJCLIENT_WRAPPER_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct cuobj_client_ctx cuobj_client_ctx_t; + +// cuObj operation values (match cuObjOpType_t in cuobjclient headers). +#define CUOBJCLIENT_OP_GET 0 +#define CUOBJCLIENT_OP_PUT 1 + +// Lifecycle +cuobj_client_ctx_t* cuobj_client_create(void); +void cuobj_client_destroy(cuobj_client_ctx_t *ctx); + +// GPU memory management +void* cuobj_client_cuda_malloc(size_t size); +int cuobj_client_cuda_free(void *ptr); +int cuobj_client_cuda_memset(void *ptr, int value, size_t size); +int cuobj_client_cuda_memcpy_h2d(void *dst_dev, const void *src_host, size_t size); +int cuobj_client_cuda_memcpy_d2h(void *dst_host, const void *src_dev, size_t size); +const char* cuobj_client_cuda_error_string(int cuda_err); + +// cuObject memory registration +int cuobj_client_register_descriptor(cuobj_client_ctx_t *ctx, void *ptr, size_t size); +int cuobj_client_unregister_descriptor(cuobj_client_ctx_t *ctx, void *ptr); + +// RDMA token management. +// Returns an allocated descriptor string on success, NULL on failure. +// Caller must release it via cuobj_client_put_rdma_token. +char* cuobj_client_get_rdma_token(cuobj_client_ctx_t *ctx, + void *ptr, + size_t size, + size_t buffer_offset, + int operation); +int cuobj_client_put_rdma_token(cuobj_client_ctx_t *ctx, char *token); + +// Utility +uint64_t cuobj_client_ptr_to_u64(void *ptr); + +#ifdef __cplusplus +} +#endif + +#endif // CUOBJCLIENT_WRAPPER_H diff --git a/cuwrapper/cuobjserver_wrapper.cpp b/cuwrapper/cuobjserver_wrapper.cpp new file mode 100644 index 00000000..713d55e7 --- /dev/null +++ b/cuwrapper/cuobjserver_wrapper.cpp @@ -0,0 +1,247 @@ +// 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. + +// C wrapper implementation for cuObjServer. +// Bridges extern "C" functions to the C++ cuObjServer class. + +#include "cuobjserver_wrapper.h" +#include "cuobjserver.h" + +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Session management — runtime symbol resolution +// +// libcuobjserver.so may export startRDMASession / closeRDMASession under one +// of two C++ mangled names depending on when the library was compiled: +// +// Newer header layout (RDMAConnection base class): +// _ZN14RDMAConnection16startRDMASessionEv +// _ZN14RDMAConnection16closeRDMASessionEv +// +// Older layout (method directly on cuObjServer): +// _ZN11cuObjServer16startRDMASessionEv +// _ZN11cuObjServer16closeRDMASessionEv +// +// We resolve lazily at first call so neither name needs to be present at +// link time, and the code works with both library generations. +// --------------------------------------------------------------------------- + +typedef int (*rdma_start_fn_t)(void *); +typedef void (*rdma_close_fn_t)(void *); + +// Verbose wrapper logs are enabled when cuobj_server_set_telem_flags includes +// info/debug bits (configured by cuserver -debug). +static std::atomic g_verbose_logs{false}; + +static rdma_start_fn_t find_start_rdma_session() { + void *proc = dlopen(nullptr, RTLD_LAZY); + if (!proc) return nullptr; + rdma_start_fn_t fn = reinterpret_cast( + dlsym(proc, "_ZN14RDMAConnection16startRDMASessionEv")); + if (!fn) + fn = reinterpret_cast( + dlsym(proc, "_ZN11cuObjServer16startRDMASessionEv")); + dlclose(proc); + return fn; +} + +static rdma_close_fn_t find_close_rdma_session() { + void *proc = dlopen(nullptr, RTLD_LAZY); + if (!proc) return nullptr; + rdma_close_fn_t fn = reinterpret_cast( + dlsym(proc, "_ZN14RDMAConnection16closeRDMASessionEv")); + if (!fn) + fn = reinterpret_cast( + dlsym(proc, "_ZN11cuObjServer16closeRDMASessionEv")); + dlclose(proc); + return fn; +} + +extern "C" { + +cuobj_server_t* cuobj_server_create(const char *ip, unsigned short port, unsigned proto) { + try { + auto *srv = new cuObjServer(ip, port, proto); + return reinterpret_cast(srv); + } catch (...) { + return nullptr; + } +} + +// Build a cuObjRDMATunable from the flat C struct, shared by +// cuobj_server_create_with_config and cuobj_server_init_rdma_config. +static cuObjRDMATunable tunables_from_c(const cuobj_rdma_tunables_t *t) { + cuObjRDMATunable config; + config.setNumDcis(t->num_dcis); + config.setCqDepth(t->cq_depth); + config.setDcKey(t->dc_key); + config.setServiceLevel(t->service_level); + config.setTimeout(t->timeout); + config.setHopLimit(t->hop_limit); + config.setPkeyIndex(t->pkey_index); + config.setDelayInterval(t->delay_interval); + config.setDelayMode(static_cast(t->delay_mode)); + config.setRetryCount(t->retry_cnt); + config.setQPResetOnFailure(t->qp_reset_on_failure != 0); + config.setTrafficClass(t->traffic_class); + config.setMaxRdAtomic(t->max_rd_atomic); + return config; +} + +cuobj_server_t* cuobj_server_create_with_config(const char *ip, unsigned short port, + unsigned proto, + const cuobj_rdma_tunables_t *t) { + try { + cuObjRDMATunable config = tunables_from_c(t); + auto *srv = new cuObjServer(ip, port, proto, config); + return reinterpret_cast(srv); + } catch (...) { + return nullptr; + } +} + +void cuobj_server_destroy(cuobj_server_t *srv) { + delete reinterpret_cast(srv); +} + +int cuobj_server_start_session(cuobj_server_t *srv) { + static rdma_start_fn_t fn = find_start_rdma_session(); + if (!fn) { + // Symbol not exported — library calls startRDMASession() internally + // from the cuObjServer constructor. Verify the session actually came + // up instead of unconditionally reporting success. + auto *s = reinterpret_cast(srv); + return s->isConnected() ? 0 : -1; + } + int rc = fn(reinterpret_cast(srv)); + if (rc != 0) + fprintf(stderr, "cuobjwrapper: startRDMASession returned %d\n", rc); + return rc; +} + +void cuobj_server_close_session(cuobj_server_t *srv) { + static rdma_close_fn_t fn = find_close_rdma_session(); + if (!fn) { + // Symbol not exported — session cleanup handled by destructor. + return; + } + fn(reinterpret_cast(srv)); +} + +int cuobj_server_is_connected(cuobj_server_t *srv) { + auto *s = reinterpret_cast(srv); + return s->isConnected() ? 1 : 0; +} + +void* cuobj_server_alloc_host_buffer(cuobj_server_t *srv, size_t size) { + auto *s = reinterpret_cast(srv); + return s->allocHostBuffer(size); +} + +void cuobj_server_free_host_buffer(void *ptr) { + free(ptr); +} + +cuobj_rdma_buffer_t* cuobj_server_register_buffer(cuobj_server_t *srv, void *ptr, size_t size) { + auto *s = reinterpret_cast(srv); + return s->registerBuffer(ptr, size); +} + +void cuobj_server_deregister_buffer(cuobj_server_t *srv, cuobj_rdma_buffer_t *buf) { + auto *s = reinterpret_cast(srv); + s->deRegisterBuffer(buf); +} + +uint16_t cuobj_server_allocate_channel(cuobj_server_t *srv) { + auto *s = reinterpret_cast(srv); + return s->allocateChannelId(); +} + +void cuobj_server_free_channel(cuobj_server_t *srv, uint16_t channel_id) { + auto *s = reinterpret_cast(srv); + s->freeChannelId(channel_id); +} + +ssize_t cuobj_server_handle_get(cuobj_server_t *srv, + const char *key, + cuobj_rdma_buffer_t *local_buf, + uint64_t remote_buf_start, + size_t size, + const char *rdma_descr, + uint16_t channel) { + auto *s = reinterpret_cast(srv); + std::string k(key); + std::string descr(rdma_descr); + ibv_wc_status wc_status = IBV_WC_SUCCESS; + ssize_t rc = s->handleGetObject(k, local_buf, remote_buf_start, size, descr, + channel, 0, &wc_status); + if (rc < 0 && g_verbose_logs.load()) { + fprintf(stderr, + "cuobjwrapper: handleGetObject rc=%zd channel=%u wc_status=%d\n", + rc, static_cast(channel), static_cast(wc_status)); + } + return rc; +} + +ssize_t cuobj_server_handle_put(cuobj_server_t *srv, + const char *key, + cuobj_rdma_buffer_t *local_buf, + uint64_t remote_buf_start, + size_t size, + const char *rdma_descr, + uint16_t channel) { + auto *s = reinterpret_cast(srv); + std::string k(key); + std::string descr(rdma_descr); + ibv_wc_status wc_status = IBV_WC_SUCCESS; + ssize_t rc = s->handlePutObject(k, local_buf, remote_buf_start, size, descr, + channel, 0, &wc_status); + if (rc < 0 && g_verbose_logs.load()) { + fprintf(stderr, + "cuobjwrapper: handlePutObject rc=%zd channel=%u wc_status=%d\n", + rc, static_cast(channel), static_cast(wc_status)); + } + return rc; +} + +void cuobj_server_setup_telemetry(int use_otel) { + cuObjServer::setupTelemetry(use_otel != 0, &std::cout); +} + +void cuobj_server_shutdown_telemetry(void) { + cuObjServer::shutdownTelemetry(); +} + +void cuobj_server_set_telem_flags(unsigned flags) { + g_verbose_logs.store((flags & 0x0003u) != 0u); + cuObjServer::setTelemFlags(flags); +} + +int cuobj_server_init_rdma_config(cuobj_server_t *srv, const cuobj_rdma_tunables_t *t) { + auto *s = reinterpret_cast(srv); + try { + s->initRDMAConfigParams(tunables_from_c(t)); + return 0; + } catch (...) { + return -1; + } +} + +} // extern "C" diff --git a/cuwrapper/cuobjserver_wrapper.h b/cuwrapper/cuobjserver_wrapper.h new file mode 100644 index 00000000..227ecd24 --- /dev/null +++ b/cuwrapper/cuobjserver_wrapper.h @@ -0,0 +1,117 @@ +// 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. + +// C wrapper for the cuObjServer C++ class. +// Provides a C-linkage interface suitable for CGO consumption. + +#ifndef CUOBJSERVER_WRAPPER_H +#define CUOBJSERVER_WRAPPER_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Opaque handles +typedef struct cuobj_server cuobj_server_t; +typedef struct rdma_buffer cuobj_rdma_buffer_t; + +// Error codes matching cuObjErr_t +#define CUOBJ_OK 0 +#define CUOBJ_FAIL 1 + +// cuobj_server lifecycle +cuobj_server_t* cuobj_server_create(const char *ip, unsigned short port, unsigned proto); +void cuobj_server_destroy(cuobj_server_t *srv); + +// RDMA session +int cuobj_server_start_session(cuobj_server_t *srv); +void cuobj_server_close_session(cuobj_server_t *srv); +int cuobj_server_is_connected(cuobj_server_t *srv); + +// Host buffer allocation +void* cuobj_server_alloc_host_buffer(cuobj_server_t *srv, size_t size); +void cuobj_server_free_host_buffer(void *ptr); + +// Buffer registration +cuobj_rdma_buffer_t* cuobj_server_register_buffer(cuobj_server_t *srv, void *ptr, size_t size); +void cuobj_server_deregister_buffer(cuobj_server_t *srv, cuobj_rdma_buffer_t *buf); + +// Channel management +uint16_t cuobj_server_allocate_channel(cuobj_server_t *srv); +void cuobj_server_free_channel(cuobj_server_t *srv, uint16_t channel_id); + +// Data transfer (synchronous, no poll_delay override) +// +// handleGetObject: RDMA WRITE server→client (serves a GET request) +// Returns bytes transferred or -1 on error. +ssize_t cuobj_server_handle_get(cuobj_server_t *srv, + const char *key, + cuobj_rdma_buffer_t *local_buf, + uint64_t remote_buf_start, + size_t size, + const char *rdma_descr, + uint16_t channel); + +// handlePutObject: RDMA READ client→server (serves a PUT request) +// Returns bytes transferred or -1 on error. +ssize_t cuobj_server_handle_put(cuobj_server_t *srv, + const char *key, + cuobj_rdma_buffer_t *local_buf, + uint64_t remote_buf_start, + size_t size, + const char *rdma_descr, + uint16_t channel); + +// Telemetry (optional) +void cuobj_server_setup_telemetry(int use_otel); +void cuobj_server_shutdown_telemetry(void); +void cuobj_server_set_telem_flags(unsigned flags); + +// RDMA tunable parameters — flat C struct for CGO compatibility. +// Field names and defaults match cuObjRDMATunableParam in cuobjrdma.h. +typedef struct { + int num_dcis; // default 128 + unsigned cq_depth; // default 640 + unsigned long dc_key; // default 0xffeeddcc + int service_level; // default 0 + uint8_t timeout; // default 16 + unsigned hop_limit; // default 4 + int pkey_index; // default 0 + uint32_t delay_interval; // default 5000 ns + int delay_mode; // 0=none 1=batch 2=entry 3=adaptive; default 1 + uint8_t retry_cnt; // default 7 + int qp_reset_on_failure; // bool as int; default 1 (true) + unsigned traffic_class; // default 96 + int max_rd_atomic; // default 0 (auto) +} cuobj_rdma_tunables_t; + +// Apply RDMA tuning parameters to an existing connection object. +// Takes effect on the next reconnection if called after session start. +// Returns 0 on success, -1 on error. +int cuobj_server_init_rdma_config(cuobj_server_t *srv, const cuobj_rdma_tunables_t *t); + +// Create a cuObjServer with tunable parameters applied before the session +// starts. This is the preferred constructor when non-default tunables are +// needed, since the library starts the RDMA session inside the constructor. +cuobj_server_t* cuobj_server_create_with_config(const char *ip, unsigned short port, unsigned proto, const cuobj_rdma_tunables_t *t); + +#ifdef __cplusplus +} +#endif + +#endif // CUOBJSERVER_WRAPPER_H diff --git a/cuwrapper/rdma_host_client_wrapper.cpp b/cuwrapper/rdma_host_client_wrapper.cpp new file mode 100644 index 00000000..5e48e708 --- /dev/null +++ b/cuwrapper/rdma_host_client_wrapper.cpp @@ -0,0 +1,460 @@ +// 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. + +// Host-memory RDMA client wrapper implementation. +// +// Builds a passive DC Target (DCT) endpoint with libibverbs + mlx5 direct +// verbs so a cuObjServer can RDMA READ/WRITE the client's registered host +// memory. No CUDA/GPU dependency. + +#include "rdma_host_client_wrapper.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +struct rdma_host_client { + struct ibv_context *ctx = nullptr; + struct ibv_pd *pd = nullptr; + struct ibv_cq *cq = nullptr; + struct ibv_srq *srq = nullptr; + struct ibv_qp *dct = nullptr; // DC target QP + + uint8_t port_num = 1; + int gid_index = 0; + uint64_t dc_key = 0; + + uint16_t lid = 0; + union ibv_gid gid = {}; + uint32_t dctn = 0; + + void *buf = nullptr; + size_t buf_len = 0; + struct ibv_mr *mr = nullptr; + + std::string token; + std::string last_error; +}; + +static void set_err(rdma_host_client_t *c, const std::string &msg) { + if (c) { + c->last_error = msg; + } +} + +static bool gid_is_zero(const union ibv_gid &gid) { + for (int i = 0; i < 16; i++) { + if (gid.raw[i] != 0) { + return false; + } + } + return true; +} + +static bool gid_is_link_local(const union ibv_gid &gid) { + return gid.raw[0] == 0xfe && (gid.raw[1] & 0xc0) == 0x80; +} + +static bool gid_is_ipv4_mapped(const union ibv_gid &gid) { + for (int i = 0; i < 10; i++) { + if (gid.raw[i] != 0) { + return false; + } + } + return gid.raw[10] == 0xff && gid.raw[11] == 0xff; +} + +// gid_is_roce_v2 reports whether GID index i is a RoCEv2 entry. mlx5 +// commonly exposes a RoCEv1 and RoCEv2 GID pair with identical raw bytes at +// adjacent indices (v1 first), so the raw GID content alone cannot +// distinguish them; only ibv_query_gid_ex()'s reported gid_type can. If the +// query fails (e.g. native InfiniBand link layer, where the v1/v2 +// distinction doesn't apply), the GID is not excluded on this basis. +static bool gid_is_roce_v2(struct ibv_context *ctx, uint8_t port_num, int index) { + struct ibv_gid_entry entry; + memset(&entry, 0, sizeof(entry)); + if (ibv_query_gid_ex(ctx, port_num, static_cast(index), &entry, 0)) { + return true; + } + return entry.gid_type == IBV_GID_TYPE_ROCE_V2; +} + +static bool select_gid(rdma_host_client_t *c) { + if (c->gid_index >= 0) { + if (ibv_query_gid(c->ctx, c->port_num, c->gid_index, &c->gid)) { + set_err(c, "ibv_query_gid failed"); + return false; + } + return true; + } + + struct ibv_port_attr port_attr; + memset(&port_attr, 0, sizeof(port_attr)); + if (ibv_query_port(c->ctx, c->port_num, &port_attr)) { + set_err(c, "ibv_query_port failed while selecting GID"); + return false; + } + + int fallback_gid_index = -1; + union ibv_gid fallback_gid = {}; + int v1_fallback_gid_index = -1; + union ibv_gid v1_fallback_gid = {}; + int v1_non_ipv4_gid_index = -1; + union ibv_gid v1_non_ipv4_gid = {}; + + // Prefer non-zero, non-link-local, non-IPv4-mapped, RoCEv2 GIDs. + // Use ibv_query_gid() only for broad provider compatibility. + for (int i = 0; i < port_attr.gid_tbl_len; i++) { + union ibv_gid gid = {}; + if (ibv_query_gid(c->ctx, c->port_num, i, &gid)) { + continue; + } + if (gid_is_zero(gid)) { + continue; + } + if (gid_is_link_local(gid)) { + continue; + } + bool is_v2 = gid_is_roce_v2(c->ctx, c->port_num, i); + if (gid_is_ipv4_mapped(gid)) { + if (is_v2) { + if (fallback_gid_index < 0) { + fallback_gid_index = i; + fallback_gid = gid; + } + } else if (v1_fallback_gid_index < 0) { + v1_fallback_gid_index = i; + v1_fallback_gid = gid; + } + continue; + } + if (is_v2) { + c->gid_index = i; + c->gid = gid; + return true; + } + if (v1_non_ipv4_gid_index < 0) { + v1_non_ipv4_gid_index = i; + v1_non_ipv4_gid = gid; + } + } + + // Fallbacks, in order of preference: RoCEv2 IPv4-mapped, then any + // RoCEv1 candidate. RoCEv1 is only used if no RoCEv2 GID is present at + // all (e.g. non-Mellanox providers where ibv_query_gid_ex reports + // unknown type for every entry, in which case gid_is_roce_v2 treats all + // entries as usable and the earlier non-IPv4-mapped branch above already + // returns on the first candidate). + if (fallback_gid_index >= 0) { + c->gid_index = fallback_gid_index; + c->gid = fallback_gid; + return true; + } + if (v1_non_ipv4_gid_index >= 0) { + c->gid_index = v1_non_ipv4_gid_index; + c->gid = v1_non_ipv4_gid; + return true; + } + if (v1_fallback_gid_index >= 0) { + c->gid_index = v1_fallback_gid_index; + c->gid = v1_fallback_gid; + return true; + } + + set_err(c, + "no usable GID found on selected RDMA port; " + "set VGWRDMA_GID_INDEX to a valid RoCE GID index from `ibv_devinfo -v`"); + return false; +} + +// build_token encodes the RDMA descriptor for the currently registered region. +// +// IMPORTANT: the exact on-wire token format is defined by the NVIDIA cuObject +// RDMA DC protocol as parsed by libcuobjserver. It is a colon-delimited, +// lowercase-hex string whose 7 fields are, in order: +// +// # | Field | Type | Width +// --|--------------------------------------------|----------|----------- +// 1 | Remote base address (GPUMEM/SYSMEM) | uint64 | 16 chars +// 2 | Max size of buffer region from base addr | uint32 | 8 chars +// 3 | Remote key (rkey) | uint32 | 8 chars +// 4 | LID of the client NIC | uint16 | 4 chars +// 5 | DCTN | uint32 | 6 chars +// 6 | GID present (1|0) | bool | 1 char +// 7 | GID of client NIC | 16 bytes | 32 chars +// +// Example: "0102030405060708:01020304:01020304:0102:010203:1:0102030405060708090a0b0c0d0e0f10" +// +// The canonical definition (consumed by the Go gateway) lives in +// cumiddleware/cuobj.go next to HeaderRDMAToken; keep the two in sync. +// Assumptions kept explicit here: +// 1) Field widths and lowercase hex formatting are strict parser contracts. +// 2) GID is serialized as the raw 16 bytes in order (no hextet splitting). +// 3) Descriptor size field is uint32; oversized buffers are rejected. +static void build_token(rdma_host_client_t *c) { + char gid_hex[33]; + for (int i = 0; i < 16; i++) { + snprintf(&gid_hex[i * 2], 3, "%02x", c->gid.raw[i]); + } + gid_hex[32] = '\0'; + + char buf[320]; + snprintf(buf, sizeof(buf), + "%016llx:%08x:%08x:%04x:%06x:%1x:%s", + (unsigned long long)(uintptr_t)c->buf, + static_cast(c->buf_len), + c->mr ? c->mr->rkey : 0u, + static_cast(c->lid), + static_cast(c->dctn), + 1, // GID present (RoCE) + gid_hex); + c->token = buf; +} + +static bool build_dct(rdma_host_client_t *c) { + c->cq = ibv_create_cq(c->ctx, 1, nullptr, nullptr, 0); + if (!c->cq) { + set_err(c, "ibv_create_cq failed"); + return false; + } + + struct ibv_srq_init_attr srq_attr; + memset(&srq_attr, 0, sizeof(srq_attr)); + srq_attr.attr.max_wr = 1; + srq_attr.attr.max_sge = 1; + c->srq = ibv_create_srq(c->pd, &srq_attr); + if (!c->srq) { + set_err(c, "ibv_create_srq failed"); + return false; + } + + struct ibv_qp_init_attr_ex attr_ex; + memset(&attr_ex, 0, sizeof(attr_ex)); + attr_ex.pd = c->pd; + attr_ex.send_cq = c->cq; + attr_ex.recv_cq = c->cq; + attr_ex.srq = c->srq; + attr_ex.qp_type = IBV_QPT_DRIVER; + attr_ex.comp_mask = IBV_QP_INIT_ATTR_PD; + + struct mlx5dv_qp_init_attr dv_attr; + memset(&dv_attr, 0, sizeof(dv_attr)); + dv_attr.comp_mask = MLX5DV_QP_INIT_ATTR_MASK_DC; + dv_attr.dc_init_attr.dc_type = MLX5DV_DCTYPE_DCT; + dv_attr.dc_init_attr.dct_access_key = c->dc_key; + + c->dct = mlx5dv_create_qp(c->ctx, &attr_ex, &dv_attr); + if (!c->dct) { + set_err(c, "mlx5dv_create_qp (DCT) failed"); + return false; + } + + // INIT + struct ibv_qp_attr qpa; + memset(&qpa, 0, sizeof(qpa)); + qpa.qp_state = IBV_QPS_INIT; + qpa.pkey_index = 0; + qpa.port_num = c->port_num; + qpa.qp_access_flags = IBV_ACCESS_REMOTE_WRITE | IBV_ACCESS_REMOTE_READ; + if (ibv_modify_qp(c->dct, &qpa, + IBV_QP_STATE | IBV_QP_PKEY_INDEX | IBV_QP_PORT | + IBV_QP_ACCESS_FLAGS)) { + set_err(c, "ibv_modify_qp DCT->INIT failed"); + return false; + } + + struct ibv_port_attr port_attr; + memset(&port_attr, 0, sizeof(port_attr)); + if (ibv_query_port(c->ctx, c->port_num, &port_attr)) { + set_err(c, "ibv_query_port failed"); + return false; + } + c->lid = port_attr.lid; + + // RTR — DCT only needs INIT->RTR (no RTS for targets). + memset(&qpa, 0, sizeof(qpa)); + qpa.qp_state = IBV_QPS_RTR; + qpa.path_mtu = port_attr.active_mtu; + qpa.min_rnr_timer = 12; + qpa.ah_attr.is_global = 1; + qpa.ah_attr.port_num = c->port_num; + qpa.ah_attr.grh.hop_limit = 4; + qpa.ah_attr.grh.sgid_index = (uint8_t)c->gid_index; + qpa.ah_attr.grh.traffic_class = 0; + if (ibv_modify_qp(c->dct, &qpa, + IBV_QP_STATE | IBV_QP_MIN_RNR_TIMER | IBV_QP_AV | + IBV_QP_PATH_MTU)) { + set_err(c, "ibv_modify_qp DCT->RTR failed"); + return false; + } + + // Read DCT number after INIT->RTR is complete. Some provider paths may + // expose qp_num late; encoding 0 here breaks remote addressing. + c->dctn = c->dct->qp_num; + if (c->dctn == 0) { + set_err(c, "DCT number is zero after RTR transition"); + return false; + } + + return true; +} + +extern "C" { + +rdma_host_client_t* rdma_host_client_create(const char *dev_name, + uint8_t port_num, + int gid_index, + uint64_t dc_key) { + auto *c = new (std::nothrow) rdma_host_client(); + if (!c) { + return nullptr; + } + c->port_num = port_num ? port_num : 1; + c->gid_index = gid_index; + c->dc_key = dc_key; + + int num = 0; + struct ibv_device **list = ibv_get_device_list(&num); + if (!list || num == 0) { + set_err(c, "ibv_get_device_list found no devices"); + if (list) ibv_free_device_list(list); + return c; // return handle so caller can read last_error + } + + struct ibv_device *dev = nullptr; + if (dev_name && dev_name[0]) { + for (int i = 0; i < num; i++) { + if (strcmp(ibv_get_device_name(list[i]), dev_name) == 0) { + dev = list[i]; + break; + } + } + if (!dev) { + set_err(c, std::string("RDMA device not found: ") + dev_name); + ibv_free_device_list(list); + return c; + } + } else { + dev = list[0]; + } + + c->ctx = ibv_open_device(dev); + ibv_free_device_list(list); + if (!c->ctx) { + set_err(c, "ibv_open_device failed"); + return c; + } + + c->pd = ibv_alloc_pd(c->ctx); + if (!c->pd) { + set_err(c, "ibv_alloc_pd failed"); + return c; + } + + if (!select_gid(c)) { + return c; + } + + if (!build_dct(c)) { + return c; + } + + c->last_error.clear(); + return c; +} + +void rdma_host_client_free(rdma_host_client_t *c) { + if (!c) { + return; + } + if (c->mr) { + ibv_dereg_mr(c->mr); + c->mr = nullptr; + } + if (c->buf) { + free(c->buf); + c->buf = nullptr; + } + c->buf_len = 0; + c->token.clear(); +} + +void* rdma_host_client_alloc(rdma_host_client_t *c, size_t size) { + if (!c || size == 0) { + return nullptr; + } + if (size > static_cast(std::numeric_limits::max())) { + set_err(c, "requested buffer size exceeds descriptor uint32 max"); + return nullptr; + } + rdma_host_client_free(c); + + void *ptr = nullptr; + if (posix_memalign(&ptr, 4096, size) != 0 || !ptr) { + set_err(c, "posix_memalign failed"); + return nullptr; + } + memset(ptr, 0, size); + + struct ibv_mr *mr = ibv_reg_mr(c->pd, ptr, size, + IBV_ACCESS_LOCAL_WRITE | + IBV_ACCESS_REMOTE_WRITE | + IBV_ACCESS_REMOTE_READ); + if (!mr) { + set_err(c, "ibv_reg_mr failed"); + free(ptr); + return nullptr; + } + + c->buf = ptr; + c->buf_len = size; + c->mr = mr; + build_token(c); + return ptr; +} + +const char* rdma_host_client_token(rdma_host_client_t *c) { + if (!c || c->token.empty()) { + return nullptr; + } + return c->token.c_str(); +} + +const char* rdma_host_client_last_error(rdma_host_client_t *c) { + if (!c || c->last_error.empty()) { + return nullptr; + } + return c->last_error.c_str(); +} + +void rdma_host_client_destroy(rdma_host_client_t *c) { + if (!c) { + return; + } + rdma_host_client_free(c); + if (c->dct) ibv_destroy_qp(c->dct); + if (c->srq) ibv_destroy_srq(c->srq); + if (c->cq) ibv_destroy_cq(c->cq); + if (c->pd) ibv_dealloc_pd(c->pd); + if (c->ctx) ibv_close_device(c->ctx); + delete c; +} + +} // extern "C" diff --git a/cuwrapper/rdma_host_client_wrapper.h b/cuwrapper/rdma_host_client_wrapper.h new file mode 100644 index 00000000..6e8a0be1 --- /dev/null +++ b/cuwrapper/rdma_host_client_wrapper.h @@ -0,0 +1,80 @@ +// 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. + +// Host-memory RDMA client wrapper. +// +// This wrapper implements the *client* side of the cuObject RDMA DC (Dynamically +// Connected) protocol using libibverbs + mlx5 direct-verbs, WITHOUT any CUDA/GPU +// dependency. It is intended for RDMA-capable hosts that have no GPU and cannot +// use the official NVIDIA libcuobjclient library (which requires CUDA). +// +// Role in the protocol: the cuObjServer (gateway) is the RDMA *initiator* +// (it owns the DCI QPs and issues RDMA READ for PUT / RDMA WRITE for GET). +// The client is the passive *target*: it exposes a DC Target (DCT) QP plus a +// registered memory region, and encodes their coordinates into an RDMA +// descriptor token that travels to the server in an S3 request header. During +// the actual data transfer the client CPU is not involved — the NIC services +// the server's reads/writes against the registered region via the DCT. +// +// The C ABI below is CGO-friendly (opaque handle, C linkage, no C++ types). + +#ifndef RDMA_HOST_CLIENT_WRAPPER_H +#define RDMA_HOST_CLIENT_WRAPPER_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct rdma_host_client rdma_host_client_t; + +// rdma_host_client_create opens the given RDMA device and builds the passive +// DC target endpoint (PD, CQ, SRQ, DCT QP -> RTR) plus caches the port LID and +// the GID at gid_index. dc_key must match the server's DCKey tunable +// (default 0xffeeddcc). Pass dev_name = NULL to select the first device. +// Returns NULL on failure. +rdma_host_client_t* rdma_host_client_create(const char *dev_name, + uint8_t port_num, + int gid_index, + uint64_t dc_key); + +// rdma_host_client_destroy tears down the endpoint and frees any registered +// buffer. Safe to call with NULL. +void rdma_host_client_destroy(rdma_host_client_t *c); + +// rdma_host_client_alloc allocates a page-aligned host buffer of size bytes and +// registers it for remote read+write. Any previously allocated buffer is freed +// first. Returns the buffer pointer, or NULL on failure. +void* rdma_host_client_alloc(rdma_host_client_t *c, size_t size); + +// rdma_host_client_free deregisters and frees the current buffer, if any. +void rdma_host_client_free(rdma_host_client_t *c); + +// rdma_host_client_token returns the RDMA descriptor token for the currently +// registered buffer, or NULL if no buffer is registered. The returned string is +// owned by the client and remains valid until the next alloc/free/destroy. +// See build_token() in rdma_host_client_wrapper.cpp for the wire-format field table. +const char* rdma_host_client_token(rdma_host_client_t *c); + +// rdma_host_client_last_error returns a human-readable description of the most +// recent failure, or NULL if none. +const char* rdma_host_client_last_error(rdma_host_client_t *c); + +#ifdef __cplusplus +} +#endif + +#endif // RDMA_HOST_CLIENT_WRAPPER_H diff --git a/rdma/bufferpool/pool.go b/rdma/bufferpool/pool.go new file mode 100644 index 00000000..bf9259fa --- /dev/null +++ b/rdma/bufferpool/pool.go @@ -0,0 +1,98 @@ +// 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 bufferpool provides a pool of pre-registered RDMA buffers +// to avoid per-request allocation and registration overhead. +package bufferpool + +import ( + "context" + "errors" + "fmt" + + "github.com/versity/versitygw/rdma" +) + +// Pool manages a fixed set of RDMA-registered host memory buffers. +type Pool struct { + server *rdma.Server + bufSize int + ch chan *rdma.Buffer + all []*rdma.Buffer +} + +// NewPool pre-allocates and registers count buffers of bufSize bytes each. +func NewPool(server *rdma.Server, bufSize, count int) (*Pool, error) { + if count <= 0 { + return nil, errors.New("bufferpool: count must be > 0") + } + if bufSize <= 0 { + return nil, errors.New("bufferpool: bufSize must be > 0") + } + + p := &Pool{ + server: server, + bufSize: bufSize, + ch: make(chan *rdma.Buffer, count), + all: make([]*rdma.Buffer, 0, count), + } + + for i := range count { + ptr, err := server.AllocHostBuffer(bufSize) + if err != nil { + p.Close() + return nil, fmt.Errorf("bufferpool: alloc buffer %d: %w", i, err) + } + + buf, err := server.RegisterBuffer(ptr, bufSize) + if err != nil { + server.FreeHostBuffer(ptr) + p.Close() + return nil, fmt.Errorf("bufferpool: register buffer %d: %w", i, err) + } + + p.all = append(p.all, buf) + p.ch <- buf + } + + return p, nil +} + +// Acquire blocks until a buffer is available or ctx is cancelled. +// Returns the buffer and its backing byte slice. +func (p *Pool) Acquire(ctx context.Context) (*rdma.Buffer, []byte, error) { + select { + case buf := <-p.ch: + return buf, buf.Slice(), nil + case <-ctx.Done(): + return nil, nil, ctx.Err() + } +} + +// Release returns a buffer to the pool. +func (p *Pool) Release(buf *rdma.Buffer) { + p.ch <- buf +} + +// Close deregisters and frees all buffers. The pool must not be used afterward. +func (p *Pool) Close() { + for _, buf := range p.all { + // Capture the host pointer before DeregisterBuffer clears it, so the + // underlying allocation from AllocHostBuffer can still be freed. + ptr := buf.HostPtr() + p.server.DeregisterBuffer(buf) + p.server.FreeHostBuffer(ptr) + } + p.all = nil +} diff --git a/rdma/hostclient/client_linux.go b/rdma/hostclient/client_linux.go new file mode 100644 index 00000000..aa4a553e --- /dev/null +++ b/rdma/hostclient/client_linux.go @@ -0,0 +1,143 @@ +// 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. + +//go:build linux && amd64 && cgo + +// Package hostclient implements the client side of the cuObject RDMA DC +// protocol using libibverbs + mlx5 direct verbs, with no CUDA/GPU dependency. +// +// It is the host-memory counterpart to the GPU-based NVIDIA libcuobjclient: +// it registers host memory, exposes a passive DC target the gateway can RDMA +// READ/WRITE, and produces the RDMA descriptor token the gateway expects in +// the S3 request headers. +package hostclient + +/* +#cgo CFLAGS: -I${SRCDIR}/../../cuwrapper +#cgo LDFLAGS: -L${SRCDIR}/.. -l:libhostclientwrapper.a -libverbs -lmlx5 -lstdc++ +#include +#include "rdma_host_client_wrapper.h" +*/ +import "C" + +import ( + "errors" + "fmt" + "unsafe" +) + +// DefaultDCKey is the cuObjServer default Dynamic Connection key. The client +// DC target must use a key matching the server's DCKey tunable. +const DefaultDCKey uint64 = 0xffeeddcc + +// Client owns a passive DC target endpoint and a single registered host buffer. +// A Client is not safe for concurrent use. +type Client struct { + c *C.rdma_host_client_t + buf unsafe.Pointer + size int +} + +// NewClient opens the RDMA device and builds the DC target endpoint. +// dev selects the device by name (e.g. "mlx5_0"); empty picks the first. +// port is the HCA port (usually 1). gidIndex selects the RoCE GID (see +// `ibv_devinfo -v`). dcKey must match the server's DCKey (DefaultDCKey by +// default). +func NewClient(dev string, port uint8, gidIndex int, dcKey uint64) (*Client, error) { + var cdev *C.char + if dev != "" { + cdev = C.CString(dev) + defer C.free(unsafe.Pointer(cdev)) + } + h := C.rdma_host_client_create(cdev, C.uint8_t(port), C.int(gidIndex), C.uint64_t(dcKey)) + if h == nil { + return nil, errors.New("hostclient: allocation failed") + } + c := &Client{c: h} + if msg := C.rdma_host_client_last_error(h); msg != nil { + err := fmt.Errorf("hostclient: %s", C.GoString(msg)) + c.Close() + return nil, err + } + return c, nil +} + +// Register allocates and registers a host buffer of the given size and returns +// a Go slice backed by it. The slice is valid until the next Register call or +// Close. Copy PUT data into the slice before the transfer; read GET data out +// of it afterward. +func (c *Client) Register(size int) ([]byte, error) { + if c.c == nil { + return nil, errors.New("hostclient: closed") + } + if size <= 0 { + return nil, fmt.Errorf("hostclient: invalid size %d", size) + } + ptr := C.rdma_host_client_alloc(c.c, C.size_t(size)) + if ptr == nil { + return nil, fmt.Errorf("hostclient: register failed: %s", c.lastError()) + } + c.buf = unsafe.Pointer(ptr) + c.size = size + return unsafe.Slice((*byte)(ptr), size), nil +} + +// Token returns the RDMA descriptor token for the currently registered buffer. +// See cumiddleware.HeaderRDMAToken for the canonical wire-format field table. +func (c *Client) Token() (string, error) { + if c.c == nil { + return "", errors.New("hostclient: closed") + } + t := C.rdma_host_client_token(c.c) + if t == nil { + return "", errors.New("hostclient: no registered buffer") + } + return C.GoString(t), nil +} + +// Buffer returns the current registered buffer as a Go slice, or nil. +func (c *Client) Buffer() []byte { + if c.c == nil || c.buf == nil || c.size == 0 { + return nil + } + return unsafe.Slice((*byte)(c.buf), c.size) +} + +// BufferAddr returns the virtual address of the registered buffer. This is the +// remote base address the gateway uses as the RDMA start offset; it matches the +// address encoded in Token. +func (c *Client) BufferAddr() uint64 { + if c.c == nil || c.buf == nil { + return 0 + } + return uint64(uintptr(c.buf)) +} + +// Close releases the buffer and tears down the endpoint. +func (c *Client) Close() { + if c.c == nil { + return + } + C.rdma_host_client_destroy(c.c) + c.c = nil + c.buf = nil + c.size = 0 +} + +func (c *Client) lastError() string { + if msg := C.rdma_host_client_last_error(c.c); msg != nil { + return C.GoString(msg) + } + return "unknown error" +} diff --git a/rdma/hostclient/client_stub.go b/rdma/hostclient/client_stub.go new file mode 100644 index 00000000..ff90fe25 --- /dev/null +++ b/rdma/hostclient/client_stub.go @@ -0,0 +1,48 @@ +// 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. + +//go:build !(linux && amd64 && cgo) + +// Package hostclient is stubbed on platforms without RDMA verbs support. +package hostclient + +import "errors" + +// DefaultDCKey is the cuObjServer default Dynamic Connection key. +const DefaultDCKey uint64 = 0xffeeddcc + +var errNotSupported = errors.New("hostclient: RDMA host client is only supported on linux/amd64 with cgo") + +// Client is a non-functional stub on unsupported platforms. +type Client struct{} + +// NewClient always returns an unsupported-platform error on this build. +func NewClient(dev string, port uint8, gidIndex int, dcKey uint64) (*Client, error) { + return nil, errNotSupported +} + +// Register always returns an unsupported-platform error on this build. +func (c *Client) Register(size int) ([]byte, error) { return nil, errNotSupported } + +// Token always returns an unsupported-platform error on this build. +func (c *Client) Token() (string, error) { return "", errNotSupported } + +// Buffer returns nil on this build. +func (c *Client) Buffer() []byte { return nil } + +// BufferAddr returns 0 on this build. +func (c *Client) BufferAddr() uint64 { return 0 } + +// Close is a no-op on this build. +func (c *Client) Close() {} diff --git a/rdma/rdma.go b/rdma/rdma.go new file mode 100644 index 00000000..051624cf --- /dev/null +++ b/rdma/rdma.go @@ -0,0 +1,298 @@ +//go:build linux && amd64 && cgo + +// Package rdma provides Go bindings to libcuobjserver via CGO. +package rdma + +/* +#cgo CFLAGS: -I${SRCDIR}/../include -I${SRCDIR}/../cuwrapper +#cgo LDFLAGS: -L${SRCDIR} -l:libcuobjwrapper.a -L/usr/lib64 -lcuobjserver -lstdc++ -ldl +#include "cuobjserver_wrapper.h" +#include +*/ +import "C" + +import ( + "errors" + "fmt" + "sync" + "sync/atomic" + "unsafe" +) + +const ( + cuobjLogPathInfo = 0x0001 + cuobjLogPathDebug = 0x0002 + cuobjLogPathError = 0x0004 + + // cuobjProtoRDMADCV1 is the CUOBJ_PROTO_RDMA_DC_V1 protocol identifier. + cuobjProtoRDMADCV1 = 1001 +) + +var debugTelemetryEnabled atomic.Bool + +// ConfigureTelemetry controls cuObjServer telemetry logging. +// When debug is true, enable info+debug+error logs to stderr/stdout path. +func ConfigureTelemetry(debug bool) { + debugTelemetryEnabled.Store(debug) + C.cuobj_server_setup_telemetry(0) + if debug { + C.cuobj_server_set_telem_flags(C.uint(cuobjLogPathInfo | cuobjLogPathDebug | cuobjLogPathError)) + return + } + C.cuobj_server_set_telem_flags(C.uint(cuobjLogPathError)) +} + +// DebugTelemetryEnabled reports whether verbose RDMA diagnostics are enabled. +func DebugTelemetryEnabled() bool { + return debugTelemetryEnabled.Load() +} + +// Buffer wraps an RDMA-registered memory region. +type Buffer struct { + cbuf *C.cuobj_rdma_buffer_t + hostPtr unsafe.Pointer + size int +} + +// HostPtr returns the underlying host memory pointer. +func (b *Buffer) HostPtr() unsafe.Pointer { return b.hostPtr } + +// Size returns the buffer size in bytes. +func (b *Buffer) Size() int { return b.size } + +// Slice returns the buffer contents as a Go byte slice backed by C-allocated +// memory. Returns nil after DeregisterBuffer. +func (b *Buffer) Slice() []byte { + return unsafe.Slice((*byte)(b.hostPtr), b.size) +} + +// Server wraps a cuObjServer instance. +type Server struct { + csrv *C.cuobj_server_t + sessionOpen bool + mu sync.Mutex +} + +// tunablesToC converts a RDMATunables value to the equivalent C struct. +func tunablesToC(t RDMATunables) C.cuobj_rdma_tunables_t { + var ct C.cuobj_rdma_tunables_t + ct.num_dcis = C.int(t.NumDCIs) + ct.cq_depth = C.uint(t.CQDepth) + ct.dc_key = C.ulong(t.DCKey) + ct.service_level = C.int(t.ServiceLevel) + ct.timeout = C.uint8_t(t.Timeout) + ct.hop_limit = C.uint(t.HopLimit) + ct.pkey_index = C.int(t.PKeyIndex) + ct.delay_interval = C.uint32_t(t.DelayInterval) + ct.delay_mode = C.int(t.DelayMode) + ct.retry_cnt = C.uint8_t(t.RetryCount) + if t.QPResetOnFailure { + ct.qp_reset_on_failure = 1 + } + ct.traffic_class = C.uint(t.TrafficClass) + ct.max_rd_atomic = C.int(t.MaxRdAtomic) + return ct +} + +// NewServer creates a cuObjServer bound to the given RDMA IP and port. +// Uses CUOBJ_PROTO_RDMA_DC_V1 (1001). If tunables is non-nil, the 4-argument +// constructor is used so the tunable parameters apply to the initial session +// started by the constructor. Pass nil to use library defaults. +func NewServer(ip string, port uint16, tunables *RDMATunables) (*Server, error) { + cip := C.CString(ip) + defer C.free(unsafe.Pointer(cip)) + + var csrv *C.cuobj_server_t + if tunables != nil { + ct := tunablesToC(*tunables) + csrv = C.cuobj_server_create_with_config(cip, C.ushort(port), cuobjProtoRDMADCV1, &ct) + } else { + csrv = C.cuobj_server_create(cip, C.ushort(port), cuobjProtoRDMADCV1) + } + if csrv == nil { + return nil, fmt.Errorf("rdma: failed to create cuObjServer on %s:%d", ip, port) + } + srv := &Server{csrv: csrv} + // Some library versions start the session as part of construction; + // record that readiness so StartSession can be a no-op and Close/CloseSession + // use a consistent ownership model. + if srv.IsConnected() { + srv.sessionOpen = true + } + return srv, nil +} + +// StartSession initiates the RDMA listening session. +// StartSession must not be called concurrently with CloseSession or Close. +func (s *Server) StartSession() error { + s.mu.Lock() + alreadyOpen := s.sessionOpen + s.mu.Unlock() + if alreadyOpen { + return nil + } + rc := C.cuobj_server_start_session(s.csrv) + if rc != 0 { + if s.IsConnected() { + s.mu.Lock() + s.sessionOpen = true + s.mu.Unlock() + return nil + } + return fmt.Errorf("rdma: startRDMASession failed (rc=%d)", rc) + } + s.mu.Lock() + s.sessionOpen = true + s.mu.Unlock() + return nil +} + +// InitRDMAConfig applies RDMA tuning parameters. Must be called before StartSession. +func (s *Server) InitRDMAConfig(t RDMATunables) error { + ct := tunablesToC(t) + if rc := C.cuobj_server_init_rdma_config(s.csrv, &ct); rc != 0 { + return fmt.Errorf("rdma: initRDMAConfigParams failed (rc=%d)", rc) + } + return nil +} + +// IsConnected returns the RDMA connection status. +func (s *Server) IsConnected() bool { + return C.cuobj_server_is_connected(s.csrv) != 0 +} + +// AllocHostBuffer allocates a 4KB-aligned host buffer of the given size. +func (s *Server) AllocHostBuffer(size int) (unsafe.Pointer, error) { + if size <= 0 { + return nil, fmt.Errorf("rdma: allocHostBuffer size %d must be positive", size) + } + ptr := C.cuobj_server_alloc_host_buffer(s.csrv, C.size_t(size)) + if ptr == nil { + return nil, fmt.Errorf("rdma: allocHostBuffer(%d) failed", size) + } + return ptr, nil +} + +// FreeHostBuffer releases a buffer previously allocated by AllocHostBuffer. +func (s *Server) FreeHostBuffer(ptr unsafe.Pointer) { + if ptr != nil { + C.cuobj_server_free_host_buffer(ptr) + } +} + +// RegisterBuffer registers a host memory region for RDMA and returns a Buffer handle. +func (s *Server) RegisterBuffer(ptr unsafe.Pointer, size int) (*Buffer, error) { + if ptr == nil { + return nil, errors.New("rdma: registerBuffer ptr must not be nil") + } + if size <= 0 { + return nil, fmt.Errorf("rdma: registerBuffer size %d must be positive", size) + } + cbuf := C.cuobj_server_register_buffer(s.csrv, ptr, C.size_t(size)) + if cbuf == nil { + return nil, errors.New("rdma: registerBuffer failed") + } + return &Buffer{cbuf: cbuf, hostPtr: ptr, size: size}, nil +} + +// DeregisterBuffer deregisters a previously registered RDMA buffer. +func (s *Server) DeregisterBuffer(buf *Buffer) { + if buf != nil && buf.cbuf != nil { + C.cuobj_server_deregister_buffer(s.csrv, buf.cbuf) + buf.cbuf = nil + buf.hostPtr = nil + buf.size = 0 + } +} + +// AllocateChannel obtains a unique channel ID for concurrent RDMA operations. +func (s *Server) AllocateChannel() (uint16, error) { + ch := C.cuobj_server_allocate_channel(s.csrv) + if ch == C.UINT16_MAX { + return 0, errors.New("rdma: no free channel IDs") + } + return uint16(ch), nil +} + +// FreeChannel releases a previously allocated channel ID. +func (s *Server) FreeChannel(id uint16) { + C.cuobj_server_free_channel(s.csrv, C.uint16_t(id)) +} + +// HandleGet performs an RDMA WRITE (server→client) to serve a GET request. +// The local buffer must already contain the data to send. +// Returns bytes transferred. +func (s *Server) HandleGet(key string, buf *Buffer, remoteStart uint64, size int64, rdmaDescr string, channel uint16) (int64, error) { + if buf == nil || buf.cbuf == nil { + return 0, errors.New("rdma: invalid or deregistered buffer") + } + if size <= 0 { + return 0, fmt.Errorf("rdma: transfer size %d must be positive", size) + } + if size > MaxTransferSize { + return 0, fmt.Errorf("rdma: transfer size %d exceeds max %d", size, MaxTransferSize) + } + + ckey := C.CString(key) + defer C.free(unsafe.Pointer(ckey)) + cdescr := C.CString(rdmaDescr) + defer C.free(unsafe.Pointer(cdescr)) + + n := C.cuobj_server_handle_get(s.csrv, ckey, buf.cbuf, + C.uint64_t(remoteStart), C.size_t(size), cdescr, C.uint16_t(channel)) + if n < 0 { + return 0, fmt.Errorf("rdma: handleGetObject failed (rc=%d)", n) + } + return int64(n), nil +} + +// HandlePut performs an RDMA READ (client→server) to serve a PUT request. +// After return, the local buffer contains the data read from the client. +// Returns bytes transferred. +func (s *Server) HandlePut(key string, buf *Buffer, remoteStart uint64, size int64, rdmaDescr string, channel uint16) (int64, error) { + if buf == nil || buf.cbuf == nil { + return 0, errors.New("rdma: invalid or deregistered buffer") + } + if size <= 0 { + return 0, fmt.Errorf("rdma: transfer size %d must be positive", size) + } + if size > MaxTransferSize { + return 0, fmt.Errorf("rdma: transfer size %d exceeds max %d", size, MaxTransferSize) + } + + ckey := C.CString(key) + defer C.free(unsafe.Pointer(ckey)) + cdescr := C.CString(rdmaDescr) + defer C.free(unsafe.Pointer(cdescr)) + + n := C.cuobj_server_handle_put(s.csrv, ckey, buf.cbuf, + C.uint64_t(remoteStart), C.size_t(size), cdescr, C.uint16_t(channel)) + if n < 0 { + return 0, fmt.Errorf("rdma: handlePutObject failed (rc=%d)", n) + } + return int64(n), nil +} + +// CloseSession tears down the RDMA session. +func (s *Server) CloseSession() { + s.mu.Lock() + defer s.mu.Unlock() + if s.csrv != nil && s.sessionOpen { + C.cuobj_server_close_session(s.csrv) + s.sessionOpen = false + } +} + +// Close destroys the cuObjServer instance. The Server must not be used afterward. +func (s *Server) Close() { + s.mu.Lock() + defer s.mu.Unlock() + if s.csrv != nil { + if s.sessionOpen { + C.cuobj_server_close_session(s.csrv) + s.sessionOpen = false + } + C.cuobj_server_destroy(s.csrv) + s.csrv = nil + } +} diff --git a/rdma/rdma_common.go b/rdma/rdma_common.go new file mode 100644 index 00000000..409546c0 --- /dev/null +++ b/rdma/rdma_common.go @@ -0,0 +1,68 @@ +package rdma + +// MaxTransferSize is the cuObjServer limit per RDMA operation (1 GiB). +const MaxTransferSize = 1 << 30 + +// RDMATunables holds RDMA connection tuning parameters that map to +// cuObjRDMATunableParam. Pass a non-nil pointer in CuServerOpts to apply +// custom settings before the RDMA session starts; nil means library defaults. +// +// Use DefaultRDMATunables() to start from the library defaults and adjust +// only the fields you care about. +type RDMATunables struct { + // NumDCIs controls the maximum number of concurrent RDMA connections + // (Dynamic Connection Interfaces). Default: 128. + NumDCIs int + // CQDepth is the completion queue depth, limiting outstanding ops. + // Default: 640. + CQDepth uint32 + // DCKey is the InfiniBand Dynamic Connection security key. All clients + // must use a matching key. Change this from the default in production. + // Default: 0xffeeddcc. + DCKey uint64 + // ServiceLevel sets the IB QoS service level. Default: 0. + ServiceLevel int + // Timeout is the QP ACK timeout exponent: 4.096 * 2^Timeout µs. + // Default: 16 (~268 ms). + Timeout uint8 + // HopLimit is the IB packet hop limit (analogous to IP TTL). Default: 4. + HopLimit uint32 + // PKeyIndex is the IB partition key index. Default: 0. + PKeyIndex int + // DelayInterval is the polling delay in nanoseconds. Default: 5000. + DelayInterval uint32 + // DelayMode selects the polling strategy: + // 0 = none, 1 = batch (default), 2 = per-entry, 3 = adaptive. + DelayMode int + // RetryCount is the QP retry count (0–7). Default: 7. + RetryCount uint8 + // QPResetOnFailure controls whether the QP is reset after an RDMA failure. + // Default: true. + QPResetOnFailure bool + // TrafficClass sets the IB traffic class / DSCP bits. Default: 96. + TrafficClass uint32 + // MaxRdAtomic is the max outstanding RDMA reads per DCI QP. + // 0 means auto-detect from device capabilities. Default: 0. + MaxRdAtomic int +} + +// DefaultRDMATunables returns a RDMATunables pre-populated with the +// cuObjServer library defaults. Use this as a starting point and override +// only the fields you need. +func DefaultRDMATunables() RDMATunables { + return RDMATunables{ + NumDCIs: 128, + CQDepth: 640, + DCKey: 0xffeeddcc, + ServiceLevel: 0, + Timeout: 16, + HopLimit: 4, + PKeyIndex: 0, + DelayInterval: 5000, + DelayMode: 1, // CUOBJ_DELAY_BATCH + RetryCount: 7, + QPResetOnFailure: true, + TrafficClass: 96, + MaxRdAtomic: 0, + } +} diff --git a/rdma/rdma_stub.go b/rdma/rdma_stub.go new file mode 100644 index 00000000..36d2cac9 --- /dev/null +++ b/rdma/rdma_stub.go @@ -0,0 +1,86 @@ +//go:build !(linux && amd64 && cgo) + +// Package rdma provides Go bindings to libcuobjserver. +// This file is a stub for platforms without RDMA support. +package rdma + +import ( + "errors" + "unsafe" +) + +var errNotSupported = errors.New("rdma: not supported on this platform") + +// Buffer wraps an RDMA-registered memory region (stub — always zero-valued). +type Buffer struct { + hostPtr unsafe.Pointer + size int +} + +// HostPtr returns the underlying host memory pointer. +func (b *Buffer) HostPtr() unsafe.Pointer { return b.hostPtr } + +// Size returns the buffer size in bytes. +func (b *Buffer) Size() int { return b.size } + +// Slice returns the buffer contents as a Go byte slice. +func (b *Buffer) Slice() []byte { return []byte{} } + +// Server wraps a cuObjServer instance (stub — always returns errNotSupported). +type Server struct{} + +// ConfigureTelemetry is a no-op on platforms without RDMA support. +func ConfigureTelemetry(debug bool) {} + +// DebugTelemetryEnabled always reports false on stub platforms. +func DebugTelemetryEnabled() bool { return false } + +// NewServer always returns errNotSupported on this platform. +func NewServer(ip string, port uint16, tunables *RDMATunables) (*Server, error) { + return nil, errNotSupported +} + +// StartSession always returns errNotSupported on this platform. +func (s *Server) StartSession() error { return errNotSupported } + +// InitRDMAConfig always returns errNotSupported on this platform. +func (s *Server) InitRDMAConfig(t RDMATunables) error { return errNotSupported } + +// IsConnected always returns false on this platform. +func (s *Server) IsConnected() bool { return false } + +// AllocHostBuffer always returns errNotSupported on this platform. +func (s *Server) AllocHostBuffer(size int) (unsafe.Pointer, error) { return nil, errNotSupported } + +// FreeHostBuffer is a no-op on this platform. +func (s *Server) FreeHostBuffer(ptr unsafe.Pointer) {} + +// RegisterBuffer always returns errNotSupported on this platform. +func (s *Server) RegisterBuffer(ptr unsafe.Pointer, size int) (*Buffer, error) { + return nil, errNotSupported +} + +// DeregisterBuffer is a no-op on this platform. +func (s *Server) DeregisterBuffer(buf *Buffer) {} + +// AllocateChannel always returns errNotSupported on this platform. +func (s *Server) AllocateChannel() (uint16, error) { return 0, errNotSupported } + +// FreeChannel is a no-op on this platform. +func (s *Server) FreeChannel(id uint16) {} + +// HandleGet always returns errNotSupported on this platform. +func (s *Server) HandleGet(key string, buf *Buffer, remoteStart uint64, size int64, rdmaDescr string, channel uint16) (int64, error) { + return 0, errNotSupported +} + +// HandlePut always returns errNotSupported on this platform. +func (s *Server) HandlePut(key string, buf *Buffer, remoteStart uint64, size int64, rdmaDescr string, channel uint16) (int64, error) { + return 0, errNotSupported +} + +// CloseSession is a no-op on this platform. +func (s *Server) CloseSession() {} + +// Close is a no-op on this platform. +func (s *Server) Close() {}