mirror of
https://github.com/versity/versitygw.git
synced 2026-09-23 00:14:15 +00:00
Merge pull request #2365 from potatogim/rc-parity-pr3
rdma: expose live RC sessions on the admin server
This commit is contained in:
+135
-8
@@ -35,11 +35,14 @@ import (
|
||||
"github.com/versity/versitygw/embedgw"
|
||||
"github.com/versity/versitygw/internal/netutil"
|
||||
"github.com/versity/versitygw/internal/rdmamode"
|
||||
"github.com/versity/versitygw/metrics"
|
||||
"github.com/versity/versitygw/rdma"
|
||||
"github.com/versity/versitygw/rdma/rcroutes"
|
||||
"github.com/versity/versitygw/rdma/rcserver"
|
||||
"github.com/versity/versitygw/s3api"
|
||||
"github.com/versity/versitygw/s3api/middlewares"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -120,6 +123,15 @@ var (
|
||||
rdmaCQDepth uint
|
||||
rdmaRetryCount uint
|
||||
rdmaTunablesSet bool
|
||||
rcMaxSessions uint64
|
||||
rcMaxUserSessions uint64
|
||||
rcMaxStagingBytes uint64
|
||||
rcMaxUserStagingBytes uint64
|
||||
rcMaxQPs uint64
|
||||
rcMaxUserQPs uint64
|
||||
rcMaxReadySlots uint64
|
||||
rcPrepTimeoutMs uint64
|
||||
rcExecTimeoutMs uint64
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -898,6 +910,69 @@ func initFlags() []cli.Flag {
|
||||
EnvVars: []string{"VGW_RDMA_RC_ENABLE"},
|
||||
Destination: &rdmaRCEnable,
|
||||
},
|
||||
&cli.Uint64Flag{
|
||||
Name: "rdma-rc-max-sessions",
|
||||
Usage: "maximum concurrent hipobj-rc-v2 sessions (default 1024)",
|
||||
EnvVars: []string{"VGW_RDMA_RC_MAX_SESSIONS"},
|
||||
Value: 1024,
|
||||
Destination: &rcMaxSessions,
|
||||
},
|
||||
&cli.Uint64Flag{
|
||||
Name: "rdma-rc-max-user-sessions",
|
||||
Usage: "per-principal session limit for the hipobj-rc-v2 data plane (default 64)",
|
||||
EnvVars: []string{"VGW_RDMA_RC_MAX_USER_SESSIONS"},
|
||||
Value: 64,
|
||||
Destination: &rcMaxUserSessions,
|
||||
},
|
||||
&cli.Uint64Flag{
|
||||
Name: "rdma-rc-max-staging-bytes",
|
||||
Usage: "total staging buffer budget for hipobj-rc-v2 sessions in bytes (default 4294967296)",
|
||||
EnvVars: []string{"VGW_RDMA_RC_MAX_STAGING_BYTES"},
|
||||
Value: 4 << 30,
|
||||
Destination: &rcMaxStagingBytes,
|
||||
},
|
||||
&cli.Uint64Flag{
|
||||
Name: "rdma-rc-max-user-staging-bytes",
|
||||
Usage: "per-principal staging buffer budget for the hipobj-rc-v2 data plane in bytes (default 1073741824)",
|
||||
EnvVars: []string{"VGW_RDMA_RC_MAX_USER_STAGING_BYTES"},
|
||||
Value: 1 << 30,
|
||||
Destination: &rcMaxUserStagingBytes,
|
||||
},
|
||||
&cli.Uint64Flag{
|
||||
Name: "rdma-rc-max-qps",
|
||||
Usage: "maximum queue pairs for the hipobj-rc-v2 data plane (default 1024)",
|
||||
EnvVars: []string{"VGW_RDMA_RC_MAX_QPS"},
|
||||
Value: 1024,
|
||||
Destination: &rcMaxQPs,
|
||||
},
|
||||
&cli.Uint64Flag{
|
||||
Name: "rdma-rc-max-user-qps",
|
||||
Usage: "per-principal queue pair limit for the hipobj-rc-v2 data plane (default 16)",
|
||||
EnvVars: []string{"VGW_RDMA_RC_MAX_USER_QPS"},
|
||||
Value: 16,
|
||||
Destination: &rcMaxUserQPs,
|
||||
},
|
||||
&cli.Uint64Flag{
|
||||
Name: "rdma-rc-max-ready-slots",
|
||||
Usage: "concurrent READY transfers admitted by the hipobj-rc-v2 data plane (default 64)",
|
||||
EnvVars: []string{"VGW_RDMA_RC_MAX_READY_SLOTS"},
|
||||
Value: 64,
|
||||
Destination: &rcMaxReadySlots,
|
||||
},
|
||||
&cli.Uint64Flag{
|
||||
Name: "rdma-rc-prep-timeout-ms",
|
||||
Usage: "milliseconds a hipobj-rc-v2 session may wait for READY after PREPARE (default 100000)",
|
||||
EnvVars: []string{"VGW_RDMA_RC_PREP_TIMEOUT_MS"},
|
||||
Value: 100000,
|
||||
Destination: &rcPrepTimeoutMs,
|
||||
},
|
||||
&cli.Uint64Flag{
|
||||
Name: "rdma-rc-exec-timeout-ms",
|
||||
Usage: "milliseconds a hipobj-rc-v2 READY transfer may run (default 30000)",
|
||||
EnvVars: []string{"VGW_RDMA_RC_EXEC_TIMEOUT_MS"},
|
||||
Value: 30000,
|
||||
Destination: &rcExecTimeoutMs,
|
||||
},
|
||||
&cli.UintFlag{
|
||||
Name: "rdma-port",
|
||||
Usage: "port for RDMA listener",
|
||||
@@ -1006,6 +1081,25 @@ func runGateway(ctx context.Context, be backend.Backend) error {
|
||||
return errors.New(msg)
|
||||
}
|
||||
}
|
||||
if v2On {
|
||||
// RC-only settings are irrelevant when the hipobj-rc-v2
|
||||
// data plane is not running; stale environment values
|
||||
// must not block v1-only or plain-S3 startup.
|
||||
v2s := rdmamode.V2Settings{
|
||||
MaxSessions: rcMaxSessions,
|
||||
MaxUserSessions: rcMaxUserSessions,
|
||||
MaxStagingBytes: rcMaxStagingBytes,
|
||||
MaxUserStagingBytes: rcMaxUserStagingBytes,
|
||||
MaxQPs: rcMaxQPs,
|
||||
MaxUserQPs: rcMaxUserQPs,
|
||||
MaxReadySlots: rcMaxReadySlots,
|
||||
TPrepMs: rcPrepTimeoutMs,
|
||||
TExecMs: rcExecTimeoutMs,
|
||||
}
|
||||
if msg := rdmamode.V2ValidationError(v2s); msg != "" {
|
||||
return errors.New(msg)
|
||||
}
|
||||
}
|
||||
|
||||
var s3Opts []s3api.Option
|
||||
if v1On {
|
||||
@@ -1163,14 +1257,16 @@ func runGateway(ctx context.Context, be backend.Backend) error {
|
||||
rcSvc, err := rcserver.Init(rcserver.DeviceOpts{
|
||||
GidHint: rcGidHint,
|
||||
Port: 1,
|
||||
MaxSessions: 1024,
|
||||
MaxUserSessions: 64,
|
||||
MaxStagingBytes: 4 << 30,
|
||||
MaxUserStagingBytes: 1 << 30,
|
||||
MaxQPs: 1024,
|
||||
MaxUserQPs: 16,
|
||||
TPrepMs: 100000,
|
||||
TExecMs: 30000,
|
||||
MaxSessions: uint32(rcMaxSessions),
|
||||
MaxUserSessions: uint32(rcMaxUserSessions),
|
||||
MaxStagingBytes: rcMaxStagingBytes,
|
||||
MaxUserStagingBytes: rcMaxUserStagingBytes,
|
||||
MaxQPs: uint32(rcMaxQPs),
|
||||
MaxUserQPs: uint32(rcMaxUserQPs),
|
||||
MaxReadySlots: uint32(rcMaxReadySlots),
|
||||
TPrepMs: rcPrepTimeoutMs,
|
||||
TExecMs: rcExecTimeoutMs,
|
||||
Debug: debug,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1203,6 +1299,37 @@ func runGateway(ctx context.Context, be backend.Backend) error {
|
||||
s3api.WithRoute("POST", "/.hipobj-rc/ready", rcAuth, rcH.Ready),
|
||||
s3api.WithRoute("POST", "/.hipobj-rc/cancel", rcAuth, rcH.Cancel),
|
||||
)
|
||||
// The RC session snapshot rides the standalone admin server
|
||||
// (started when --admin-port is given) behind the same
|
||||
// signature verification and admin role check as the other
|
||||
// admin endpoints.
|
||||
rcAdminAuth := func(ctx fiber.Ctx) error {
|
||||
if err := rcVerify(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := middlewares.IsAdmin(metrics.ActionAdminListBuckets)(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Admin routes answer with the same XML error surface as the
|
||||
// built-in admin endpoints; the default fiber error handler
|
||||
// would turn auth failures into a bare 500.
|
||||
rcAdminRoute := func(ctx fiber.Ctx) error {
|
||||
err := rcAdminAuth(ctx)
|
||||
if err == nil {
|
||||
err = rcH.AdminSnapshot(ctx)
|
||||
}
|
||||
if serr, ok := err.(s3err.S3Error); ok {
|
||||
requestID, hostID := utils.EnsureRequestIDs(ctx)
|
||||
return ctx.Status(serr.StatusCode()).Send(
|
||||
serr.XMLBody(requestID, hostID))
|
||||
}
|
||||
return err
|
||||
}
|
||||
cfg.AdminOptions = append(cfg.AdminOptions,
|
||||
s3api.WithAdminRoute("GET", "/rc-sessions", rcAdminRoute, rcH.AdminSnapshot),
|
||||
)
|
||||
} else {
|
||||
cfg.S3Options = s3Opts
|
||||
}
|
||||
|
||||
@@ -75,7 +75,33 @@ bool IBVWrapper::ensureLoaded() {
|
||||
struct ibv_send_wr *, struct ibv_send_wr **)>(load("ibv_post_send"));
|
||||
loaded = funcs_.get_device_list != nullptr && funcs_.open_device != nullptr &&
|
||||
funcs_.alloc_pd != nullptr && funcs_.create_qp != nullptr &&
|
||||
funcs_.modify_qp != nullptr && funcs_.poll_cq != nullptr;
|
||||
funcs_.modify_qp != nullptr;
|
||||
if (loaded) {
|
||||
/* poll_cq/post_send/post_recv are static inline wrappers in
|
||||
* modern verbs.h (they dispatch through cq->context->ops), so
|
||||
* dlsym cannot find them on rdma-core 61+. Resolve them from
|
||||
* the ops table of the first successfully opened context
|
||||
* instead; every context from the same device shares these
|
||||
* providers. */
|
||||
int n = 0;
|
||||
struct ibv_device **devs = funcs_.get_device_list(&n);
|
||||
struct ibv_context *probe = nullptr;
|
||||
if (devs && n > 0) probe = funcs_.open_device(devs[0]);
|
||||
if (devs) funcs_.free_device_list(devs);
|
||||
if (!probe) {
|
||||
fprintf(stderr, "rc: no RDMA device to resolve verbs ops\n");
|
||||
loaded = false;
|
||||
} else {
|
||||
funcs_.poll_cq = probe->ops.poll_cq;
|
||||
funcs_.post_recv = probe->ops.post_recv;
|
||||
funcs_.post_send = probe->ops.post_send;
|
||||
funcs_.close_device(probe);
|
||||
if (!funcs_.poll_cq || !funcs_.post_recv || !funcs_.post_send) {
|
||||
fprintf(stderr, "rc: provider ops table incomplete\n");
|
||||
loaded = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (loaded) {
|
||||
/* Mirror into the member seam for direct ibv.x() calls. */
|
||||
get_device_list = funcs_.get_device_list;
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
#include <thread>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
|
||||
#include "rc_ibv_host.h"
|
||||
#include "v2_data_phase.h"
|
||||
@@ -41,6 +43,9 @@ struct RcSession {
|
||||
V2Session core;
|
||||
uint64_t epoch = 0;
|
||||
std::atomic<uint64_t> next_nonce{1};
|
||||
/* Monotonic creation timestamp for session-age observability;
|
||||
* deadlines cannot serve that role because READY moves them. */
|
||||
uint64_t created_ms = 0;
|
||||
/* staged metadata from finish_staging (GET) or finish_put. */
|
||||
std::string etag;
|
||||
std::string version_id;
|
||||
@@ -89,6 +94,13 @@ struct rc_server {
|
||||
hipObj::DeviceHandle *device = nullptr;
|
||||
SessionTable table;
|
||||
rc_device_opts opts{};
|
||||
/* Diagnostic sink: null keeps stderr-only error reporting.
|
||||
* Reads/writes are plain loads/stores; the sink is installed
|
||||
* once at init time (before the reaper starts) and only
|
||||
* cleared by destroy after the reaper joined, so no thread
|
||||
* races an in-flight sink pointer swap. */
|
||||
rc_log_fn log_fn = nullptr;
|
||||
void *log_ctx = nullptr;
|
||||
std::atomic<uint64_t> epoch_counter{1};
|
||||
/* resource accounting (global buckets; per-principal map). */
|
||||
std::mutex acct_mtx;
|
||||
@@ -117,6 +129,22 @@ struct rc_server {
|
||||
|
||||
namespace {
|
||||
|
||||
/* Emits a diagnostic line to the installed sink (level 0 keeps
|
||||
* the stderr error stream intact by also printing there, so
|
||||
* existing deployments do not lose the only log they had).
|
||||
* Callers must not hold map_mtx/acct_mtx when calling. */
|
||||
void rcLog(const rc_server *srv, int level, const char *file, int line,
|
||||
const char *fmt, ...) {
|
||||
char buf[256];
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
vsnprintf(buf, sizeof(buf), fmt, ap);
|
||||
va_end(ap);
|
||||
if (level <= 0) fprintf(stderr, "%s\n", buf);
|
||||
rc_log_fn fn = srv->log_fn;
|
||||
if (fn) fn(srv->log_ctx, level, buf, file, line);
|
||||
}
|
||||
|
||||
RcSession *findSession(rc_server *srv, const std::string &id) {
|
||||
auto it = srv->sessions_map.find(id);
|
||||
return it == srv->sessions_map.end() ? nullptr : it->second.get();
|
||||
@@ -187,6 +215,16 @@ void reapSession(rc_server *srv, RcSession *s) {
|
||||
s->core.qp = conn.qp; /* null on success, survivor on failure */
|
||||
s->core.cq = conn.cq;
|
||||
bool destroyed = q_ok && c_ok;
|
||||
/* Terminal record for every session teardown path (expiry,
|
||||
* CANCEL, and destroy); reap_pass may have missed the final
|
||||
* state, so the last outcome observed at READY time travels
|
||||
* with the log line. */
|
||||
rcLog(srv, 2, __FILE__, __LINE__,
|
||||
"rc: session reaped id=%s op=%s target=%.96s staged=%llu "
|
||||
"qp_destroyed=%d",
|
||||
s->core.id.c_str(), s->core.op.c_str(),
|
||||
s->core.target.c_str(),
|
||||
(unsigned long long)s->staging_len, (int)destroyed);
|
||||
if (destroyed) {
|
||||
/* Same policy as releaseStaging: a failed dereg leaves the
|
||||
* MR registered against the shared PD, so the buffer stays
|
||||
@@ -257,9 +295,18 @@ std::string encodeReplyToken(hipObj::DeviceHandle *dh, uint32_t qpn) {
|
||||
|
||||
extern "C" {
|
||||
|
||||
void rc_server_set_log_sink(rc_server *srv, rc_log_fn fn, void *ctx) {
|
||||
if (!srv) return;
|
||||
srv->log_fn = fn;
|
||||
srv->log_ctx = ctx;
|
||||
}
|
||||
|
||||
int rc_server_init(const rc_device_opts *opts, rc_server **out) {
|
||||
if (!opts || !out) return RC_E_ARG;
|
||||
if (!hipObj::ibv.ensureLoaded()) return RC_E_INTERNAL;
|
||||
if (!hipObj::ibv.ensureLoaded()) {
|
||||
fprintf(stderr, "rc: cannot load libibverbs (dlopen/dlsym failed)\n");
|
||||
return RC_E_INTERNAL;
|
||||
}
|
||||
std::unique_ptr<rc_server> srv(new rc_server());
|
||||
srv->opts = *opts;
|
||||
/* ibv port numbers are 1-based; treat an unset (0) port as 1 so
|
||||
@@ -275,7 +322,10 @@ int rc_server_init(const rc_device_opts *opts, rc_server **out) {
|
||||
|
||||
int n = 0;
|
||||
struct ibv_device **devs = hipObj::ibv.get_device_list(&n);
|
||||
if (!devs || n == 0) return RC_E_INTERNAL;
|
||||
if (!devs || n == 0) {
|
||||
fprintf(stderr, "rc: no RDMA devices found (ibv_get_device_list)\n");
|
||||
return RC_E_INTERNAL;
|
||||
}
|
||||
struct ibv_device *chosen = devs[0];
|
||||
/* GID hint: pick the first device/port whose GID starts with it.
|
||||
* Query with srv->opts.port, which the normalization above has
|
||||
@@ -309,12 +359,15 @@ int rc_server_init(const rc_device_opts *opts, rc_server **out) {
|
||||
}
|
||||
if (!ctx) {
|
||||
hipObj::ibv.free_device_list(devs);
|
||||
fprintf(stderr, "rc: no verbs device matches gid_hint %.32s\n",
|
||||
opts->gid_hint ? opts->gid_hint : "");
|
||||
return RC_E_INTERNAL;
|
||||
}
|
||||
struct ibv_pd *pd = hipObj::ibv.alloc_pd(ctx);
|
||||
hipObj::ibv.free_device_list(devs);
|
||||
if (!pd) {
|
||||
hipObj::ibv.close_device(ctx);
|
||||
fprintf(stderr, "rc: alloc_pd failed\n");
|
||||
return RC_E_INTERNAL;
|
||||
}
|
||||
srv->device = new hipObj::DeviceHandle();
|
||||
@@ -563,6 +616,11 @@ int rc_prepare(rc_server *srv, const rc_prepare_req *req,
|
||||
srv->opts.t_prep_ms
|
||||
? hipObj::v2::clockSource().nowMs() + srv->opts.t_prep_ms
|
||||
: 0;
|
||||
rs.created_ms = hipObj::v2::clockSource().nowMs();
|
||||
/* The session record carries its own id copy: reap logging and the
|
||||
* terminal teardown record read core.id, while the map key is the
|
||||
* only other place the id lives. */
|
||||
rs.core.id = id;
|
||||
{
|
||||
std::lock_guard<std::mutex> g(srv->map_mtx);
|
||||
rs.staging_buf = reinterpret_cast<uint8_t *>(buf);
|
||||
@@ -703,6 +761,44 @@ int rc_session_info(rc_server *srv, rc_str_in session_id,
|
||||
return RC_OK;
|
||||
}
|
||||
|
||||
int rc_server_sessions_snapshot(rc_server *srv, rc_snapshot_cb cb,
|
||||
void *ctx) {
|
||||
if (!srv || !cb) return RC_E_ARG;
|
||||
if (srv->closing.load()) return RC_E_INTERNAL;
|
||||
|
||||
/* Fixed records so the vector can move without invalidating the
|
||||
* string pointers inside; the copies own everything the callback
|
||||
* reads, so delivery happens outside the map lock and cannot race
|
||||
* the reaper moving or erasing entries. */
|
||||
std::vector<rc_session_snapshot> recs;
|
||||
uint64_t now = hipObj::v2::clockSource().nowMs();
|
||||
{
|
||||
std::lock_guard<std::mutex> g(srv->map_mtx);
|
||||
recs.reserve(srv->sessions_map.size());
|
||||
for (const auto &kv : srv->sessions_map) {
|
||||
const RcSession &s = *kv.second;
|
||||
rc_session_snapshot r{};
|
||||
if (s.core.id.size() != 32) continue; /* live ids are 32 hex */
|
||||
if (s.core.op.size() >= sizeof(r.op)) continue;
|
||||
if (s.core.target.size() > sizeof(r.target) - 1) continue;
|
||||
memcpy(r.session_id, s.core.id.data(), 32);
|
||||
r.session_id[32] = 0;
|
||||
memcpy(r.op, s.core.op.data(), s.core.op.size());
|
||||
r.op[s.core.op.size()] = 0;
|
||||
memcpy(r.target, s.core.target.data(), s.core.target.size());
|
||||
r.target[s.core.target.size()] = 0;
|
||||
r.target_len = (uint32_t)s.core.target.size();
|
||||
r.state = (uint8_t)s.core.state;
|
||||
if (s.reap_pending) r.state |= RC_SNAPSHOT_REAP_PENDING;
|
||||
r.age_ms = s.created_ms ? now - s.created_ms : 0;
|
||||
r.staging_bytes = s.staging_len;
|
||||
recs.push_back(r);
|
||||
}
|
||||
}
|
||||
for (const auto &r : recs) cb(&r, ctx);
|
||||
return RC_OK;
|
||||
}
|
||||
|
||||
int rc_ready_transfer(rc_server *srv, const rc_ready_req *req,
|
||||
rc_ready_resp *resp) {
|
||||
if (!srv || !req || !resp) return RC_E_ARG;
|
||||
@@ -798,6 +894,19 @@ int rc_ready_transfer(rc_server *srv, const rc_ready_req *req,
|
||||
RcSession *after = findSession(srv, id);
|
||||
if (!after) return RC_E_NO_SESSION;
|
||||
|
||||
/* Keep the wire-level reason (poll status vs post failure vs
|
||||
* timeout) alongside the outcome the response carries, so a
|
||||
* VerifyFail is diagnosable without re-running the transfer.
|
||||
* Logged with the lock dropped: the sink must not block under
|
||||
* map_mtx. */
|
||||
int rlog = (r == hipObj::v2::DataPhaseResult::Ok)
|
||||
? 2
|
||||
: (r == hipObj::v2::DataPhaseResult::Busy ? 2 : 0);
|
||||
rcLog(srv, rlog, __FILE__, __LINE__,
|
||||
"rc: ready data phase session=%s op=%s outcome=%d bytes=%llu",
|
||||
id.c_str(), after->core.op.c_str(), (int)r,
|
||||
(unsigned long long)stats.bytes);
|
||||
|
||||
switch (r) {
|
||||
case hipObj::v2::DataPhaseResult::Ok:
|
||||
after->last_outcome = RC_READY_OK;
|
||||
|
||||
@@ -39,8 +39,19 @@ enum {
|
||||
|
||||
typedef struct { const char *ptr; uint32_t len; } rc_str_in;
|
||||
|
||||
/* Diagnostic log sink. Called from RC server threads (request
|
||||
* goroutines via cgo and the expiry reaper) without any server
|
||||
* lock held. `msg` and `file` are only valid for the duration of
|
||||
* the call; the sink must copy them if it needs them longer.
|
||||
* `level` is 0 (error) or 2 (debug). Passing a null sink (or
|
||||
* installing it after init) keeps stderr-only error reporting. */
|
||||
typedef void (*rc_log_fn)(void *ctx, int level, const char *msg,
|
||||
const char *file, int line);
|
||||
|
||||
typedef struct rc_server rc_server;
|
||||
|
||||
void rc_server_set_log_sink(rc_server *srv, rc_log_fn fn, void *ctx);
|
||||
|
||||
/* Device selection: matching GID prefix when gid_hint is set,
|
||||
* otherwise the first verbs device. */
|
||||
typedef struct {
|
||||
@@ -140,6 +151,30 @@ typedef struct {
|
||||
uint8_t op;
|
||||
} rc_session_info_resp;
|
||||
|
||||
/* Session observability snapshot. The record is a point-in-time copy;
|
||||
* the fields are valid only inside the callback invocation.
|
||||
* state combines the session state machine value with the reap-pending
|
||||
* marker (RC_SNAPSHOT_REAP_PENDING) because CANCEL and expiry only set
|
||||
* that marker without moving the state. */
|
||||
enum { RC_SNAPSHOT_REAP_PENDING = 0x80 };
|
||||
|
||||
typedef struct {
|
||||
char session_id[33]; /* 32 hex + NUL */
|
||||
char op[4]; /* "GET" or "PUT" */
|
||||
char target[2048];
|
||||
uint32_t target_len;
|
||||
uint8_t state; /* SessState value | RC_SNAPSHOT_REAP_PENDING */
|
||||
uint64_t age_ms; /* now - created_ms; 0 when the clock is absent */
|
||||
uint64_t staging_bytes;
|
||||
} rc_session_snapshot;
|
||||
|
||||
/* Copies every live session under the map lock into fixed records and
|
||||
* invokes cb(rec, ctx) once per record outside the lock, in map order.
|
||||
* A session whose op or target does not fit is skipped (record too
|
||||
* small), not truncated. Returns RC_E_ARG for null arguments. */
|
||||
typedef void (*rc_snapshot_cb)(const rc_session_snapshot *rec, void *ctx);
|
||||
int rc_server_sessions_snapshot(rc_server *srv, rc_snapshot_cb cb, void *ctx);
|
||||
|
||||
/* Lifecycle. destroy waits for active calls and the reaper. */
|
||||
int rc_server_init(const rc_device_opts *opts, rc_server **out);
|
||||
void rc_server_destroy(rc_server *srv);
|
||||
|
||||
@@ -75,6 +75,11 @@ type Config struct {
|
||||
// control over the admin endpoint with optionally separate TLS certs.
|
||||
AdminPorts []string
|
||||
|
||||
// AdminOptions carries extra standalone-admin-server options from
|
||||
// the embedding binary (e.g. additional admin routes). Only used
|
||||
// when AdminPorts is non-empty.
|
||||
AdminOptions []s3api.AdminOpt
|
||||
|
||||
// MaxConnections is the maximum number of concurrent TCP connections
|
||||
// accepted by the S3 API server.
|
||||
MaxConnections int
|
||||
@@ -873,6 +878,7 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error {
|
||||
admOpts := []s3api.AdminOpt{
|
||||
s3api.WithAdminConcurrencyLimiter(cfg.AdminMaxConnections, cfg.AdminMaxRequests),
|
||||
}
|
||||
admOpts = append(admOpts, cfg.AdminOptions...)
|
||||
|
||||
if corsAllowOrigin != "" {
|
||||
admOpts = append(admOpts, s3api.WithAdminCORSAllowOrigin(corsAllowOrigin))
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/versity/versitygw/backend"
|
||||
)
|
||||
@@ -68,6 +69,57 @@ func V1ValidationError(s V1Settings) string {
|
||||
}
|
||||
}
|
||||
|
||||
// V2Settings carries the hipobj-rc-v2 tunings that are validated
|
||||
// only while the RC data plane runs. Counts arrive as uint64 from
|
||||
// the CLI and narrow to uint32 at the DeviceOpts boundary, so the
|
||||
// range is checked before the narrowing cast. Timeouts feed
|
||||
// deadline arithmetic (nowMs + timeout), so an upper bound keeps
|
||||
// the sum from wrapping.
|
||||
type V2Settings struct {
|
||||
MaxSessions uint64
|
||||
MaxUserSessions uint64
|
||||
MaxStagingBytes uint64
|
||||
MaxUserStagingBytes uint64
|
||||
MaxQPs uint64
|
||||
MaxUserQPs uint64
|
||||
MaxReadySlots uint64
|
||||
TPrepMs uint64
|
||||
TExecMs uint64
|
||||
}
|
||||
|
||||
// v2TimeoutCeiling bounds the RC timeouts well below the uint64
|
||||
// wrap point of nowMs + timeout arithmetic in the C core.
|
||||
const v2TimeoutCeiling = uint64(24 * time.Hour / time.Millisecond)
|
||||
|
||||
// V2ValidationError describes the first invalid v2 setting, or
|
||||
// the empty string when every setting is valid. Stale v2 values
|
||||
// in the environment must not block v1-only or plain-S3 startup,
|
||||
// so the gateway consults this only when v2 is on.
|
||||
func V2ValidationError(s V2Settings) string {
|
||||
switch {
|
||||
case s.MaxSessions < 1 || s.MaxSessions > math.MaxUint32:
|
||||
return fmt.Sprintf("rdma-rc-max-sessions %d is out of range (1-%d)", s.MaxSessions, uint64(math.MaxUint32))
|
||||
case s.MaxUserSessions < 1 || s.MaxUserSessions > math.MaxUint32:
|
||||
return fmt.Sprintf("rdma-rc-max-user-sessions %d is out of range (1-%d)", s.MaxUserSessions, uint64(math.MaxUint32))
|
||||
case s.MaxStagingBytes < 1:
|
||||
return fmt.Sprintf("rdma-rc-max-staging-bytes %d must be positive", s.MaxStagingBytes)
|
||||
case s.MaxUserStagingBytes < 1:
|
||||
return fmt.Sprintf("rdma-rc-max-user-staging-bytes %d must be positive", s.MaxUserStagingBytes)
|
||||
case s.MaxQPs < 1 || s.MaxQPs > math.MaxUint32:
|
||||
return fmt.Sprintf("rdma-rc-max-qps %d is out of range (1-%d)", s.MaxQPs, uint64(math.MaxUint32))
|
||||
case s.MaxUserQPs < 1 || s.MaxUserQPs > math.MaxUint32:
|
||||
return fmt.Sprintf("rdma-rc-max-user-qps %d is out of range (1-%d)", s.MaxUserQPs, uint64(math.MaxUint32))
|
||||
case s.MaxReadySlots < 1 || s.MaxReadySlots > math.MaxUint32:
|
||||
return fmt.Sprintf("rdma-rc-max-ready-slots %d is out of range (1-%d)", s.MaxReadySlots, uint64(math.MaxUint32))
|
||||
case s.TPrepMs < 1 || s.TPrepMs > v2TimeoutCeiling:
|
||||
return fmt.Sprintf("rdma-rc-prep-timeout-ms %d is out of range (1-%d)", s.TPrepMs, v2TimeoutCeiling)
|
||||
case s.TExecMs < 1 || s.TExecMs > v2TimeoutCeiling:
|
||||
return fmt.Sprintf("rdma-rc-exec-timeout-ms %d is out of range (1-%d)", s.TExecMs, v2TimeoutCeiling)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Closer is the close operation of the RC session service. It is
|
||||
// idempotent.
|
||||
type Closer interface{ Close() }
|
||||
|
||||
@@ -256,3 +256,100 @@ func TestRCWrapperChainsOntoOnceBackend(t *testing.T) {
|
||||
t.Fatalf("base backend shutdown %d times, want 1", shutdowns)
|
||||
}
|
||||
}
|
||||
|
||||
func v2Defaults() V2Settings {
|
||||
return V2Settings{
|
||||
MaxSessions: 1024,
|
||||
MaxUserSessions: 64,
|
||||
MaxStagingBytes: 4 << 30,
|
||||
MaxUserStagingBytes: 1 << 30,
|
||||
MaxQPs: 1024,
|
||||
MaxUserQPs: 16,
|
||||
MaxReadySlots: 64,
|
||||
TPrepMs: 100000,
|
||||
TExecMs: 30000,
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2ValidationDefaultsPass(t *testing.T) {
|
||||
// The defaults mirror the values the gateway passed before
|
||||
// the flags existed, so a deployment that sets nothing must
|
||||
// keep starting.
|
||||
if msg := V2ValidationError(v2Defaults()); msg != "" {
|
||||
t.Fatalf("defaults rejected: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2ValidationRejectsZero(t *testing.T) {
|
||||
// Every knob must be positive: counts narrow to uint32 and
|
||||
// zero would disable a limit or a deadline in the C core.
|
||||
for name, mutate := range map[string]func(*V2Settings){
|
||||
"max-sessions": func(s *V2Settings) { s.MaxSessions = 0 },
|
||||
"max-user-sessions": func(s *V2Settings) { s.MaxUserSessions = 0 },
|
||||
"max-staging-bytes": func(s *V2Settings) { s.MaxStagingBytes = 0 },
|
||||
"max-user-staging-bytes": func(s *V2Settings) { s.MaxUserStagingBytes = 0 },
|
||||
"max-qps": func(s *V2Settings) { s.MaxQPs = 0 },
|
||||
"max-user-qps": func(s *V2Settings) { s.MaxUserQPs = 0 },
|
||||
"max-ready-slots": func(s *V2Settings) { s.MaxReadySlots = 0 },
|
||||
"prep-timeout": func(s *V2Settings) { s.TPrepMs = 0 },
|
||||
"exec-timeout": func(s *V2Settings) { s.TExecMs = 0 },
|
||||
} {
|
||||
s := v2Defaults()
|
||||
mutate(&s)
|
||||
if msg := V2ValidationError(s); msg == "" {
|
||||
t.Fatalf("%s: zero accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2ValidationCountBoundaries(t *testing.T) {
|
||||
// Counts arrive as uint64 and narrow to uint32 at the
|
||||
// DeviceOpts boundary: MaxUint32 passes, the first value
|
||||
// beyond it fails.
|
||||
fields := []struct {
|
||||
name string
|
||||
field *uint64
|
||||
}{
|
||||
{"max-sessions", new(uint64)},
|
||||
{"max-user-sessions", new(uint64)},
|
||||
{"max-qps", new(uint64)},
|
||||
{"max-user-qps", new(uint64)},
|
||||
{"max-ready-slots", new(uint64)},
|
||||
}
|
||||
for _, f := range fields {
|
||||
base := v2Defaults()
|
||||
f.field = &base.MaxSessions
|
||||
switch f.name {
|
||||
case "max-user-sessions":
|
||||
f.field = &base.MaxUserSessions
|
||||
case "max-qps":
|
||||
f.field = &base.MaxQPs
|
||||
case "max-user-qps":
|
||||
f.field = &base.MaxUserQPs
|
||||
case "max-ready-slots":
|
||||
f.field = &base.MaxReadySlots
|
||||
}
|
||||
*f.field = math.MaxUint32
|
||||
if msg := V2ValidationError(base); msg != "" {
|
||||
t.Fatalf("%s at MaxUint32 rejected: %q", f.name, msg)
|
||||
}
|
||||
*f.field = math.MaxUint32 + 1
|
||||
if msg := V2ValidationError(base); msg == "" {
|
||||
t.Fatalf("%s beyond MaxUint32 accepted", f.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2ValidationTimeoutCeiling(t *testing.T) {
|
||||
// Timeouts feed nowMs + timeout deadline arithmetic in the
|
||||
// C core, so values past the ceiling are refused.
|
||||
base := v2Defaults()
|
||||
base.TPrepMs = v2TimeoutCeiling
|
||||
if msg := V2ValidationError(base); msg != "" {
|
||||
t.Fatalf("ceiling rejected: %q", msg)
|
||||
}
|
||||
base.TPrepMs = v2TimeoutCeiling + 1
|
||||
if msg := V2ValidationError(base); msg == "" {
|
||||
t.Fatal("ceiling+1 accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// Copyright 2026 Gluesys Inc. and Jihyeon Gim
|
||||
// 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.
|
||||
|
||||
// Admin session snapshot route for the RC data plane.
|
||||
//go:build linux && amd64 && cgo
|
||||
|
||||
package rcroutes
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
|
||||
"github.com/versity/versitygw/rdma/rcserver"
|
||||
)
|
||||
|
||||
// AdminSnapshot serves the RC session snapshot on the admin surface.
|
||||
// The route is registered only when the RC plane is enabled and the
|
||||
// standalone admin server runs; middleware before it has already
|
||||
// verified the admin signature and role.
|
||||
func (h *Handler) AdminSnapshot(ctx fiber.Ctx) error {
|
||||
sessions, err := h.svc.SessionsSnapshot()
|
||||
if err != nil {
|
||||
return WriteRouteError(ctx, mapRcError(err))
|
||||
}
|
||||
type sessionRecord struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Op string `json:"op"`
|
||||
Target string `json:"target"`
|
||||
State string `json:"state"`
|
||||
ReapPending bool `json:"reap_pending"`
|
||||
AgeMs uint64 `json:"age_ms"`
|
||||
StagingBytes uint64 `json:"staging_bytes"`
|
||||
}
|
||||
stateNames := map[uint8]string{
|
||||
rcserver.SnapshotStatePrepared: "prepared",
|
||||
rcserver.SnapshotStatePublishing: "publishing",
|
||||
rcserver.SnapshotStateTransferring: "transferring",
|
||||
rcserver.SnapshotStateCompleting: "completing",
|
||||
rcserver.SnapshotStateReaping: "reaping",
|
||||
}
|
||||
out := make([]sessionRecord, 0, len(sessions))
|
||||
for _, s := range sessions {
|
||||
name, ok := stateNames[s.State]
|
||||
if !ok {
|
||||
name = "unknown"
|
||||
}
|
||||
out = append(out, sessionRecord{
|
||||
SessionID: s.SessionID,
|
||||
Op: s.Op,
|
||||
Target: s.Target,
|
||||
State: name,
|
||||
ReapPending: s.ReapPending,
|
||||
AgeMs: s.AgeMs,
|
||||
StagingBytes: s.StagingBytes,
|
||||
})
|
||||
}
|
||||
return ctx.JSON(fiber.Map{"sessions": out})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// Copyright 2026 Gluesys Inc. and Jihyeon Gim
|
||||
// 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.
|
||||
|
||||
// Admin session snapshot route (stub for platforms without RDMA
|
||||
// support).
|
||||
//go:build !(linux && amd64 && cgo)
|
||||
|
||||
package rcroutes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// AdminSnapshot is a stub; the route is never registered without the
|
||||
// RC data plane.
|
||||
func (h *Handler) AdminSnapshot(ctx fiber.Ctx) error {
|
||||
return fiber.NewError(fiber.StatusNotFound,
|
||||
errors.New("rdma rc data plane not available").Error())
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// Copyright 2026 Gluesys Inc. and Jihyeon Gim
|
||||
// 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 && cgo
|
||||
|
||||
package rcserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// heapSubstring returns a substring whose backing array is heap-allocated
|
||||
// and whose data pointer is an interior pointer, mirroring how header
|
||||
// strings reach the binding from the gateway routes.
|
||||
func heapSubstring(value string) string {
|
||||
const prefix = "prefix:"
|
||||
backing := strings.Clone(prefix + value + ":suffix")
|
||||
return backing[len(prefix) : len(prefix)+len(value)]
|
||||
}
|
||||
|
||||
// TestPrepareHeapStringsReachCValidation calls the real Prepare wrapper
|
||||
// with heap-backed strings. The request uses an invalid opcode so the C
|
||||
// entrypoint returns RC_E_ARG from its argument validation before the
|
||||
// server handle is dereferenced; the dummy handle below is never touched.
|
||||
// Before the pinnedStrIn fix this call panics at the cgo pointer check;
|
||||
// after it, the C argument validation runs and the error surfaces.
|
||||
func TestPrepareHeapStringsReachCValidation(t *testing.T) {
|
||||
const literalTarget = "/bucket1/obj1"
|
||||
heapTarget := heapSubstring(literalTarget)
|
||||
heapToken := heapSubstring(strings.Repeat("0", 88))
|
||||
for _, tc := range []struct {
|
||||
name, target, token string
|
||||
}{
|
||||
{"literal_control", literalTarget, ""},
|
||||
{"heap_target", heapTarget, ""},
|
||||
{"heap_token", literalTarget, heapToken},
|
||||
{"both_heap", heapTarget, heapToken},
|
||||
{"empty_control", "", ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var svc RCSvc
|
||||
// Test-only opaque sentinel, not an initialized rc_server.
|
||||
// Op=255 returns in C argument validation before the
|
||||
// server pointer is dereferenced.
|
||||
dummy := new(uint64)
|
||||
*(*unsafe.Pointer)(unsafe.Pointer(&svc.srv)) = unsafe.Pointer(dummy)
|
||||
resp, err := svc.Prepare(PrepareRequest{
|
||||
Op: 255,
|
||||
Size: 1,
|
||||
Target: tc.target,
|
||||
ClientToken: tc.token,
|
||||
})
|
||||
if resp != nil || !errors.Is(err, ErrArg) {
|
||||
t.Fatalf("Prepare = (%v, %v), want (nil, ErrArg)", resp, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+155
-11
@@ -27,6 +27,13 @@ package rcserver
|
||||
#cgo LDFLAGS: -L${SRCDIR}/.. -l:librcserver.a -lstdc++ -ldl -lpthread
|
||||
#include "rc_server_abi.h"
|
||||
#include <stdlib.h>
|
||||
|
||||
// Shim: a C function pointer that forwards into the exported Go
|
||||
// sink. Go closures cannot be stored as C callbacks, so the
|
||||
// level/file/line/msg are marshalled through this fixed trampoline.
|
||||
extern void rcgo_log_sink(void *ctx, int level, char *msg,
|
||||
char *file, int line);
|
||||
extern void rcgo_snapshot_cb(rc_session_snapshot *rec, void *ctx);
|
||||
*/
|
||||
import "C"
|
||||
|
||||
@@ -34,6 +41,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -76,6 +84,10 @@ type DeviceOpts struct {
|
||||
// Concurrency slots (0 picks the C-side default 64/32).
|
||||
MaxReadySlots uint32
|
||||
MaxStageSlots uint32
|
||||
// Debug enables level-2 (debug) RC diagnostics through the
|
||||
// log sink; false keeps error-only reporting. Mirrors the
|
||||
// gateway --debug flag.
|
||||
Debug bool
|
||||
}
|
||||
|
||||
// PrincipalID is the SHA-256 digest identifying the requester.
|
||||
@@ -174,6 +186,52 @@ func (s *RCSvc) Context() context.Context {
|
||||
return s.ctx
|
||||
}
|
||||
|
||||
// rcLogSink receives every diagnostic line the C server emits.
|
||||
// It is stateless and process-global on purpose: the sink must be
|
||||
// valid from init through destroy, independent of any single
|
||||
// RCSvc instance. Lines are copied out immediately (the C msg
|
||||
// buffer is only valid for the duration of the callback) and
|
||||
// written to the standard error logger. rcLogMu serializes the
|
||||
// copy because log.Logger is safe for concurrent use but keeps
|
||||
// a single writer cheap.
|
||||
var (
|
||||
rcLogMu sync.Mutex
|
||||
rcLogLevel atomic.Int32
|
||||
rcLog = log.New(log.Writer(), "vgwrdma rc: ", 0)
|
||||
)
|
||||
|
||||
//export rcgo_log_sink
|
||||
func rcgo_log_sink(_ unsafe.Pointer, level C.int, msg *C.char,
|
||||
file *C.char, line C.int) {
|
||||
// Level gating mirrors the C contract: 0 (error) always
|
||||
// arrives here; 2 (debug) only when the service opted in.
|
||||
if int32(level) > rcLogLevel.Load() {
|
||||
return
|
||||
}
|
||||
m := C.GoString(msg)
|
||||
f := ""
|
||||
if file != nil {
|
||||
f = C.GoString(file)
|
||||
}
|
||||
rcLogMu.Lock()
|
||||
rcLog.Printf("%s (%s:%d) level=%d", m, f, int(line), int(level))
|
||||
rcLogMu.Unlock()
|
||||
}
|
||||
|
||||
// installLogSink wires the C server's diagnostics into the Go
|
||||
// sink. debug selects level 2 (debug) versus level 0 (errors
|
||||
// only); the C side keeps stderr error reporting when the sink
|
||||
// is absent, so a nil install is a no-op by design.
|
||||
func installLogSink(srv *C.rc_server, debug bool) {
|
||||
if debug {
|
||||
rcLogLevel.Store(2)
|
||||
} else {
|
||||
rcLogLevel.Store(0)
|
||||
}
|
||||
C.rc_server_set_log_sink(srv,
|
||||
(*[0]byte)(C.rcgo_log_sink), nil)
|
||||
}
|
||||
|
||||
// Init opens the verbs device and returns a service.
|
||||
func Init(opts DeviceOpts) (*RCSvc, error) {
|
||||
copts := C.rc_device_opts{
|
||||
@@ -201,6 +259,7 @@ func Init(opts DeviceOpts) (*RCSvc, error) {
|
||||
if rc := C.rc_server_init(&copts, &srv); rc != C.RC_OK {
|
||||
return nil, fmt.Errorf("rcserver: init failed: status %d", int(rc))
|
||||
}
|
||||
installLogSink(srv, opts.Debug)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &RCSvc{srv: srv, ctx: ctx, cancel: cancel}, nil
|
||||
}
|
||||
@@ -313,18 +372,33 @@ func statusError(rc C.int) error {
|
||||
}
|
||||
}
|
||||
|
||||
// strIn borrows string bytes for a synchronous rc_str_in value argument.
|
||||
// For a string view embedded in a request struct passed by pointer, use
|
||||
// pinnedStrIn instead: the cgo pointer check rejects request structs that
|
||||
// contain unpinned Go string data. C must not retain or modify the borrowed
|
||||
// bytes.
|
||||
func strIn(s string) C.rc_str_in {
|
||||
if s == "" {
|
||||
return C.rc_str_in{ptr: nil, len: 0}
|
||||
}
|
||||
// The pointer must stay alive across the cgo call; every
|
||||
// wrapper keeps the string referenced until after the call.
|
||||
return C.rc_str_in{
|
||||
ptr: (*C.char)(unsafe.Pointer(unsafe.StringData(s))),
|
||||
len: C.uint32_t(len(s)),
|
||||
}
|
||||
}
|
||||
|
||||
// pinnedStrIn builds a strIn view and pins the string bytes for the
|
||||
// duration of the enclosing cgo call. The caller owns the Pinner and
|
||||
// must defer Unpin before the first pinnedStrIn call. Pinning an
|
||||
// interior pointer pins the whole backing allocation.
|
||||
func pinnedStrIn(s string, pins *runtime.Pinner) C.rc_str_in {
|
||||
in := strIn(s)
|
||||
if in.ptr != nil {
|
||||
pins.Pin(unsafe.Pointer(in.ptr))
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// cStr reads a fixed C char array of length n into a Go string.
|
||||
func cStr(p *C.char, n C.uint32_t) string {
|
||||
if n == 0 {
|
||||
@@ -338,14 +412,13 @@ func (s *RCSvc) Prepare(req PrepareRequest) (*PrepareResponse, error) {
|
||||
if s.srv == nil {
|
||||
return nil, errors.New("rcserver: service closed")
|
||||
}
|
||||
var pins runtime.Pinner
|
||||
defer pins.Unpin()
|
||||
var principal C.rc_principal_id
|
||||
copy((*[32]byte)(unsafe.Pointer(&principal.id[0]))[:], req.Principal[:])
|
||||
|
||||
var token C.rc_str_in
|
||||
if req.ClientToken != "" {
|
||||
token = strIn(req.ClientToken)
|
||||
}
|
||||
target := strIn(req.Target)
|
||||
token := pinnedStrIn(req.ClientToken, &pins)
|
||||
target := pinnedStrIn(req.Target, &pins)
|
||||
creq := C.rc_prepare_req{
|
||||
principal: principal,
|
||||
op: C.uint8_t(req.Op),
|
||||
@@ -357,8 +430,6 @@ func (s *RCSvc) Prepare(req PrepareRequest) (*PrepareResponse, error) {
|
||||
client_token: token,
|
||||
}
|
||||
var cresp C.rc_prepare_resp
|
||||
runtime.KeepAlive(req.ClientToken)
|
||||
runtime.KeepAlive(req.Target)
|
||||
rc := C.rc_prepare(s.srv, &creq, &cresp)
|
||||
if rc != C.RC_OK {
|
||||
return nil, statusError(rc)
|
||||
@@ -447,14 +518,88 @@ func (s *RCSvc) SessionInfo(sessionID string, who PrincipalID) (*SessionInfo, er
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SessionSnapshot describes a live RC session for observability.
|
||||
type SessionSnapshot struct {
|
||||
SessionID string
|
||||
Op string
|
||||
Target string
|
||||
State uint8 // SessState value, may be ORed with ReapPending
|
||||
ReapPending bool
|
||||
AgeMs uint64
|
||||
StagingBytes uint64
|
||||
}
|
||||
|
||||
// SnapshotState constants mirrored from the C ABI.
|
||||
const (
|
||||
SnapshotStatePrepared = 0
|
||||
SnapshotStatePublishing = 1
|
||||
SnapshotStateTransferring = 2
|
||||
SnapshotStateCompleting = 3
|
||||
SnapshotStateReaping = 4
|
||||
SnapshotReapPending = 0x80
|
||||
)
|
||||
|
||||
// snapshotReceiver is set by SessionsSnapshot for the duration of the
|
||||
// C call; the fixed trampoline copies each record into it. The mutex
|
||||
// covers the case of concurrent snapshot calls.
|
||||
var (
|
||||
snapshotMu sync.Mutex
|
||||
snapshotSink *[]SessionSnapshot
|
||||
)
|
||||
|
||||
//export rcgo_snapshot_cb
|
||||
func rcgo_snapshot_cb(rec *C.rc_session_snapshot, _ unsafe.Pointer) {
|
||||
sink := snapshotSink
|
||||
if sink == nil || rec == nil {
|
||||
return
|
||||
}
|
||||
st := uint8(rec.state)
|
||||
*sink = append(*sink, SessionSnapshot{
|
||||
SessionID: C.GoString(&rec.session_id[0]),
|
||||
Op: C.GoString(&rec.op[0]),
|
||||
Target: C.GoStringN(&rec.target[0], C.int(rec.target_len)),
|
||||
State: st &^ SnapshotReapPending,
|
||||
ReapPending: st&SnapshotReapPending != 0,
|
||||
AgeMs: uint64(rec.age_ms),
|
||||
StagingBytes: uint64(rec.staging_bytes),
|
||||
})
|
||||
}
|
||||
|
||||
// SessionsSnapshot copies every live session into Go-owned records.
|
||||
// The C side snapshots under its map lock and delivers the copies
|
||||
// outside it, so the callback cannot block the data plane.
|
||||
func (s *RCSvc) SessionsSnapshot() ([]SessionSnapshot, error) {
|
||||
if s.srv == nil {
|
||||
return nil, errors.New("rcserver: service closed")
|
||||
}
|
||||
if !s.TryEnter() {
|
||||
return nil, ErrInternal
|
||||
}
|
||||
defer s.Leave()
|
||||
|
||||
snapshotMu.Lock()
|
||||
defer snapshotMu.Unlock()
|
||||
var out []SessionSnapshot
|
||||
snapshotSink = &out
|
||||
defer func() { snapshotSink = nil }()
|
||||
rc := C.rc_server_sessions_snapshot(s.srv,
|
||||
(*[0]byte)(C.rcgo_snapshot_cb), nil)
|
||||
if rc != C.RC_OK {
|
||||
return nil, statusError(rc)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadyTransfer runs the data phase (READY).
|
||||
func (s *RCSvc) ReadyTransfer(req ReadyRequest) (*ReadyResponse, error) {
|
||||
if s.srv == nil {
|
||||
return nil, errors.New("rcserver: service closed")
|
||||
}
|
||||
var pins runtime.Pinner
|
||||
defer pins.Unpin()
|
||||
var principal C.rc_principal_id
|
||||
copy((*[32]byte)(unsafe.Pointer(&principal.id[0]))[:], req.Principal[:])
|
||||
in := strIn(req.SessionID)
|
||||
in := pinnedStrIn(req.SessionID, &pins)
|
||||
creq := C.rc_ready_req{
|
||||
principal: principal,
|
||||
session_id: in,
|
||||
@@ -465,7 +610,6 @@ func (s *RCSvc) ReadyTransfer(req ReadyRequest) (*ReadyResponse, error) {
|
||||
}
|
||||
var cresp C.rc_ready_resp
|
||||
rc := C.rc_ready_transfer(s.srv, &creq, &cresp)
|
||||
runtime.KeepAlive(req.SessionID)
|
||||
if rc != C.RC_OK {
|
||||
return nil, statusError(rc)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ type DeviceOpts struct {
|
||||
TExecMs uint64
|
||||
MaxReadySlots uint32
|
||||
MaxStageSlots uint32
|
||||
Debug bool
|
||||
}
|
||||
|
||||
// PrincipalID is the SHA-256 digest identifying the requester.
|
||||
@@ -110,6 +111,27 @@ type SessionInfo struct {
|
||||
Target string
|
||||
}
|
||||
|
||||
// SessionSnapshot describes a live RC session for observability.
|
||||
type SessionSnapshot struct {
|
||||
SessionID string
|
||||
Op string
|
||||
Target string
|
||||
State uint8
|
||||
ReapPending bool
|
||||
AgeMs uint64
|
||||
StagingBytes uint64
|
||||
}
|
||||
|
||||
// Snapshot state values mirrored from the cgo ABI.
|
||||
const (
|
||||
SnapshotStatePrepared = 0
|
||||
SnapshotStatePublishing = 1
|
||||
SnapshotStateTransferring = 2
|
||||
SnapshotStateCompleting = 3
|
||||
SnapshotStateReaping = 4
|
||||
SnapshotReapPending = 0x80
|
||||
)
|
||||
|
||||
// RCSvc owns the RC session server (stub).
|
||||
type RCSvc struct{}
|
||||
|
||||
@@ -151,6 +173,11 @@ func (s *RCSvc) SessionInfo(sessionID string, who PrincipalID) (*SessionInfo, er
|
||||
return nil, errNotSupported
|
||||
}
|
||||
|
||||
// SessionsSnapshot is a stub.
|
||||
func (s *RCSvc) SessionsSnapshot() ([]SessionSnapshot, error) {
|
||||
return nil, errNotSupported
|
||||
}
|
||||
|
||||
// ReadyTransfer is a stub.
|
||||
func (s *RCSvc) ReadyTransfer(req ReadyRequest) (*ReadyResponse, error) {
|
||||
return nil, errNotSupported
|
||||
|
||||
@@ -42,6 +42,15 @@ type S3AdminServer struct {
|
||||
maxConnections int
|
||||
maxRequests int
|
||||
socketPerm os.FileMode
|
||||
extraRoutes []adminRouteMount
|
||||
}
|
||||
|
||||
// adminRouteMount is a route registered on the admin app after the
|
||||
// built-in admin router ran.
|
||||
type adminRouteMount struct {
|
||||
method string
|
||||
path string
|
||||
handlers []fiber.Handler
|
||||
}
|
||||
|
||||
func NewAdminServer(be backend.Backend, root middlewares.RootUserConfig, region string, iam auth.IAMService, l s3log.AuditLogger, ctrl controllers.S3ApiController, opts ...AdminOpt) *S3AdminServer {
|
||||
@@ -95,6 +104,14 @@ func NewAdminServer(be backend.Backend, root middlewares.RootUserConfig, region
|
||||
|
||||
server.router.Init(app, be, iam, l, root, region, server.debug, server.corsAllowOrigin)
|
||||
|
||||
for _, r := range server.extraRoutes {
|
||||
args := make([]any, 0, len(r.handlers))
|
||||
for _, h := range r.handlers {
|
||||
args = append(args, h)
|
||||
}
|
||||
app.Add([]string{r.method}, r.path, args[0], args[1:]...)
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
@@ -136,6 +153,21 @@ func WithAdminSocketPerm(perm os.FileMode) AdminOpt {
|
||||
return func(s *S3AdminServer) { s.socketPerm = perm }
|
||||
}
|
||||
|
||||
// WithAdminRoute registers a route on the standalone admin server,
|
||||
// after the built-in admin routes and their middleware chain. Use it
|
||||
// for admin-surface endpoints that do not fit the S3 admin controller
|
||||
// shape.
|
||||
func WithAdminRoute(method, path string, handlers ...fiber.Handler) AdminOpt {
|
||||
return func(s *S3AdminServer) {
|
||||
copied := append([]fiber.Handler(nil), handlers...)
|
||||
s.extraRoutes = append(s.extraRoutes, adminRouteMount{
|
||||
method: method,
|
||||
path: path,
|
||||
handlers: copied,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ServeMultiPort creates listeners for multiple port specifications and serves
|
||||
// on all of them simultaneously. This supports listening on multiple ports and/or
|
||||
// addresses (e.g., [":8080", "localhost:8081"]).
|
||||
|
||||
Reference in New Issue
Block a user