Skip to content

Commit 4a5fd67

Browse files
craig[bot]shubhamdhama
andcommitted
Merge #152016
152016: server: add RPC-to-REST bridge for status endpoints r=cthumuluru-crdb,dhartunian,alyshanjahani-crl a=shubhamdhama Introduce new REST API infrastructure that serves RPCs as REST endpoints, using either gRPC or DRPC clients based on cluster settings. This bridge implements the same features as grpc-gateway that we currently use, but works with both RPC frameworks. This new implementation replaces grpc-gateway when DRPC is enabled (currently only for status endpoints). API behavior should remain identical. It's part of the broader gRPC to DRPC migration and will eventually replace grpc-gateway entirely. The experience of making an RPC available as an HTTP endpoint will be different going forward. You need to add endpoint registration and its handler. Everything else should remain the same. There are still a few gaps compared to grpc-gateway features that will be addressed in the epic. Epic: CRDB-48924 Release note: None Fixes: #151396 Fixes: #151395 Fixes: #152822 Fixes: #152826 Co-authored-by: Shubham Dhama <[email protected]>
2 parents 90db6ea + d9548b1 commit 4a5fd67

21 files changed

+519
-21
lines changed

DEPS.bzl

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4231,6 +4231,16 @@ def go_deps():
42314231
"https://storage.googleapis.com/cockroach-godeps/gomod/github.com/gorilla/mux/com_github_gorilla_mux-v1.8.0.zip",
42324232
],
42334233
)
4234+
go_repository(
4235+
name = "com_github_gorilla_schema",
4236+
build_file_proto_mode = "disable_global",
4237+
importpath = "github.com/gorilla/schema",
4238+
sha256 = "63885f95be210851d623d464698326a202274e191f53f7b7905a150e9cf04494",
4239+
strip_prefix = "github.com/gorilla/[email protected]",
4240+
urls = [
4241+
"https://storage.googleapis.com/cockroach-godeps/gomod/github.com/gorilla/schema/com_github_gorilla_schema-v1.4.1.zip",
4242+
],
4243+
)
42344244
go_repository(
42354245
name = "com_github_gorilla_securecookie",
42364246
build_file_proto_mode = "disable_global",

build/bazelutil/distdir_files.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,7 @@ DISTDIR_FILES = {
591591
"https://storage.googleapis.com/cockroach-godeps/gomod/github.com/gorilla/css/com_github_gorilla_css-v1.0.0.zip": "d854362b9d723daf613b26aae0254723a4ed1bff680683c3e2a01aeb398168e5",
592592
"https://storage.googleapis.com/cockroach-godeps/gomod/github.com/gorilla/handlers/com_github_gorilla_handlers-v1.4.2.zip": "9e47491112a46d32e372be827899e8678a881f6407f290564c63e8725b5e9a19",
593593
"https://storage.googleapis.com/cockroach-godeps/gomod/github.com/gorilla/mux/com_github_gorilla_mux-v1.8.0.zip": "7641911e00af9c91f089868333067c9cb9a58702d2c9ea821ee374940091c385",
594+
"https://storage.googleapis.com/cockroach-godeps/gomod/github.com/gorilla/schema/com_github_gorilla_schema-v1.4.1.zip": "63885f95be210851d623d464698326a202274e191f53f7b7905a150e9cf04494",
594595
"https://storage.googleapis.com/cockroach-godeps/gomod/github.com/gorilla/securecookie/com_github_gorilla_securecookie-v1.1.1.zip": "dd83a4230e11568159756bbea4d343c88df0cd1415bbbc7cd5badad6cd2ed903",
595596
"https://storage.googleapis.com/cockroach-godeps/gomod/github.com/gorilla/sessions/com_github_gorilla_sessions-v1.2.1.zip": "2c6aeebfef8062537fd7778067e5e99d4c13f79ac63114e905c97040a6e6b523",
596597
"https://storage.googleapis.com/cockroach-godeps/gomod/github.com/gorilla/websocket/com_github_gorilla_websocket-v1.4.2.zip": "d0d1728deaa06dac190bf4964c9c6395923403eae337cb3305d6dda18ef07337",

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ require (
179179
github.com/google/skylark v0.0.0-20181101142754-a5f7082aabed
180180
github.com/googleapis/gax-go/v2 v2.7.1
181181
github.com/gorilla/mux v1.8.0
182+
github.com/gorilla/schema v1.4.1
182183
github.com/goware/modvendor v0.5.0
183184
github.com/grafana/grafana-openapi-client-go v0.0.0-20240215164046-eb0e60d27cb7
184185
github.com/grpc-ecosystem/grpc-gateway v1.16.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,6 +1307,8 @@ github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z
13071307
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
13081308
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
13091309
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
1310+
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
1311+
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
13101312
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
13111313
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
13121314
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=

pkg/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1779,6 +1779,7 @@ GO_TARGETS = [
17791779
"//pkg/security:security",
17801780
"//pkg/security:security_test",
17811781
"//pkg/server/apiconstants:apiconstants",
1782+
"//pkg/server/apiinternal:apiinternal",
17821783
"//pkg/server/apiutil:apiutil",
17831784
"//pkg/server/apiutil:apiutil_test",
17841785
"//pkg/server/application_api:application_api",

pkg/security/certs_test.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -659,8 +659,17 @@ func TestUseWrongSplitCACerts(t *testing.T) {
659659
params := base.TestServerArgs{
660660
SSLCertsDir: certsDir,
661661
InsecureWebAccess: true,
662-
663662
DefaultTestTenant: base.TestIsForStuffThatShouldWorkWithSecondaryTenantsButDoesntYet(109498),
663+
// Disable DRPC for this test because it exposes a certificate
664+
// validation issue: The test removes ca-client.crt and ca-ui.crt to
665+
// test fallback to ca.crt, but the client certificates
666+
// (client.node.crt, client.root.crt) were signed by the removed
667+
// ca-client.crt. When DRPC tries to establish connections during
668+
// startup for internal APIs, it fails with "tls: unknown certificate
669+
// authority". This is a test infrastructure issue, not a production
670+
// concern, as production deployments don't remove CA certificates after
671+
// generating client certificates. See issue #153507.
672+
DefaultDRPCOption: base.TestDRPCDisabled,
664673
}
665674
srv, _, db := serverutils.StartServer(t, params)
666675
defer srv.Stopper().Stop(context.Background())

pkg/security/main_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"testing"
1111

12+
"github.com/cockroachdb/cockroach/pkg/base"
1213
"github.com/cockroachdb/cockroach/pkg/security/securityassets"
1314
"github.com/cockroachdb/cockroach/pkg/security/securitytest"
1415
"github.com/cockroachdb/cockroach/pkg/server"
@@ -25,6 +26,9 @@ func ResetTest() {
2526
func TestMain(m *testing.M) {
2627
ResetTest()
2728
serverutils.InitTestServerFactory(server.TestServerFactory)
29+
defer serverutils.TestingGlobalDRPCOption(
30+
base.TestDRPCEnabledRandomly,
31+
)()
2832
os.Exit(m.Run())
2933
}
3034

pkg/server/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ go_library(
177177
"//pkg/security/clientsecopts",
178178
"//pkg/security/username",
179179
"//pkg/server/apiconstants",
180+
"//pkg/server/apiinternal",
180181
"//pkg/server/apiutil",
181182
"//pkg/server/authserver",
182183
"//pkg/server/debug",

pkg/server/apiinternal/BUILD.bazel

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
load("@io_bazel_rules_go//go:def.bzl", "go_library")
2+
3+
go_library(
4+
name = "apiinternal",
5+
srcs = [
6+
"api_internal.go",
7+
"api_internal_status.go",
8+
],
9+
importpath = "github.com/cockroachdb/cockroach/pkg/server/apiinternal",
10+
visibility = ["//visibility:public"],
11+
deps = [
12+
"//pkg/roachpb",
13+
"//pkg/rpc/rpcbase",
14+
"//pkg/server/authserver",
15+
"//pkg/server/serverpb",
16+
"//pkg/server/srverrors",
17+
"//pkg/settings/cluster",
18+
"//pkg/util/httputil",
19+
"//pkg/util/log",
20+
"//pkg/util/protoutil",
21+
"@com_github_cockroachdb_errors//:errors",
22+
"@com_github_gogo_protobuf//jsonpb",
23+
"@com_github_gogo_protobuf//proto",
24+
"@com_github_gorilla_mux//:mux",
25+
"@com_github_gorilla_schema//:schema",
26+
"@com_github_grpc_ecosystem_grpc_gateway//runtime:go_default_library",
27+
"@org_golang_google_grpc//codes",
28+
"@org_golang_google_grpc//status",
29+
],
30+
)
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
// Copyright 2025 The Cockroach Authors.
2+
//
3+
// Use of this software is governed by the CockroachDB Software License
4+
// included in the /LICENSE file.
5+
6+
package apiinternal
7+
8+
import (
9+
"context"
10+
"io"
11+
"net/http"
12+
"net/url"
13+
"reflect"
14+
15+
"github.com/cockroachdb/cockroach/pkg/roachpb"
16+
"github.com/cockroachdb/cockroach/pkg/rpc/rpcbase"
17+
"github.com/cockroachdb/cockroach/pkg/server/authserver"
18+
"github.com/cockroachdb/cockroach/pkg/server/serverpb"
19+
"github.com/cockroachdb/cockroach/pkg/server/srverrors"
20+
"github.com/cockroachdb/cockroach/pkg/settings/cluster"
21+
"github.com/cockroachdb/cockroach/pkg/util/httputil"
22+
"github.com/cockroachdb/cockroach/pkg/util/log"
23+
"github.com/cockroachdb/cockroach/pkg/util/protoutil"
24+
"github.com/cockroachdb/errors"
25+
"github.com/gogo/protobuf/jsonpb"
26+
"github.com/gogo/protobuf/proto"
27+
"github.com/gorilla/mux"
28+
"github.com/gorilla/schema"
29+
"github.com/grpc-ecosystem/grpc-gateway/runtime"
30+
"google.golang.org/grpc/codes"
31+
"google.golang.org/grpc/status"
32+
)
33+
34+
// httpMethod represents HTTP methods supported by the API.
35+
type httpMethod string
36+
37+
// Supported HTTP methods for the internal API.
38+
const (
39+
GET httpMethod = http.MethodGet
40+
POST httpMethod = http.MethodPost
41+
)
42+
43+
var decoder = schema.NewDecoder()
44+
45+
// route defines a REST endpoint with its handler and HTTP method.
46+
type route struct {
47+
method httpMethod
48+
path string
49+
handler http.HandlerFunc
50+
}
51+
52+
// apiInternalServer provides REST endpoints that proxy to RPC services. It
53+
// serves as a bridge between HTTP REST clients and internal RPC services.
54+
type apiInternalServer struct {
55+
mux *mux.Router
56+
status serverpb.RPCStatusClient
57+
}
58+
59+
// NewAPIInternalServer creates a new REST API server that proxies to internal
60+
// RPC services. It establishes connections to the RPC services and registers
61+
// all REST endpoints.
62+
func NewAPIInternalServer(
63+
ctx context.Context, nd rpcbase.NodeDialer, localNodeID roachpb.NodeID, cs *cluster.Settings,
64+
) (*apiInternalServer, error) {
65+
status, err := serverpb.DialStatusClient(nd, ctx, localNodeID, cs)
66+
if err != nil {
67+
return nil, err
68+
}
69+
70+
r := &apiInternalServer{
71+
status: status,
72+
mux: mux.NewRouter(),
73+
}
74+
75+
r.registerStatusRoutes()
76+
77+
decoder.SetAliasTag("json")
78+
decoder.IgnoreUnknownKeys(true)
79+
80+
return r, nil
81+
}
82+
83+
// ServeHTTP implements http.Handler interface
84+
func (r *apiInternalServer) ServeHTTP(w http.ResponseWriter, req *http.Request) {
85+
r.mux.ServeHTTP(w, req)
86+
}
87+
88+
// createHandler creates an HTTP handler function that proxies requests to the
89+
// given RPC method.
90+
func createHandler[TReq, TResp protoutil.Message](
91+
rpcMethod func(context.Context, TReq) (TResp, error),
92+
) http.HandlerFunc {
93+
var zero TReq
94+
msgName := proto.MessageName(zero)
95+
msgType := proto.MessageType(msgName)
96+
if msgType == nil {
97+
panic(errors.AssertionFailedf("failed to determine request protobuf type: %s", msgName))
98+
}
99+
return func(w http.ResponseWriter, req *http.Request) {
100+
newReq := reflect.New(msgType.Elem()).Interface().(TReq)
101+
if err := executeRPC(w, req, rpcMethod, newReq); err != nil {
102+
ctx := req.Context()
103+
writeHTTPError(ctx, w, req, err)
104+
}
105+
}
106+
}
107+
108+
// executeRPC is a generic function that handles the common pattern of:
109+
// 1. Decoding HTTP request parameters (query string for GET, body for POST)
110+
// 2. Forwarding HTTP auth information to the RPC context
111+
// 3. Calling the RPC method
112+
// 4. Writing the response back to the HTTP client
113+
//
114+
// This eliminates boilerplate code across all endpoint handlers.
115+
func executeRPC[TReq, TResp protoutil.Message](
116+
w http.ResponseWriter,
117+
req *http.Request,
118+
rpcMethod func(context.Context, TReq) (TResp, error),
119+
rpcReq TReq,
120+
) error {
121+
ctx := req.Context()
122+
ctx = authserver.ForwardHTTPAuthInfoToRPCCalls(ctx, req)
123+
124+
if err := decoder.Decode(rpcReq, req.URL.Query()); err != nil {
125+
return err
126+
}
127+
if err := decodePathVars(rpcReq, mux.Vars(req)); err != nil {
128+
return err
129+
}
130+
// For POST requests, decode the request body (JSON or protobuf)
131+
if req.Method == http.MethodPost {
132+
if err := decodeRequest(req, rpcReq); err != nil {
133+
return status.Errorf(codes.InvalidArgument, "failed to decode request body: %v", err)
134+
}
135+
}
136+
137+
resp, err := rpcMethod(ctx, rpcReq)
138+
if err != nil {
139+
return err
140+
}
141+
return writeResponse(ctx, w, req, http.StatusOK, resp)
142+
}
143+
144+
func decodePathVars[TReq protoutil.Message](rpcReq TReq, vars map[string]string) error {
145+
pathParams := make(url.Values)
146+
for k, v := range vars {
147+
pathParams[k] = []string{v}
148+
}
149+
return decoder.Decode(rpcReq, pathParams)
150+
}
151+
152+
// writeHTTPError converts an error to an HTTP error response. It handles gRPC
153+
// status codes and converts them to appropriate HTTP status codes. Internal
154+
// errors are masked to avoid leaking implementation details.
155+
func writeHTTPError(ctx context.Context, w http.ResponseWriter, req *http.Request, err error) {
156+
s, ok := status.FromError(err)
157+
if !ok {
158+
s = status.New(codes.Unknown, err.Error())
159+
}
160+
161+
message := s.Message()
162+
if s.Code() == codes.Internal {
163+
message = srverrors.ErrAPIInternalErrorString
164+
log.Dev.Errorf(ctx, "failed internal API [%s] %s - %v", req.Method, req.URL.Path, err)
165+
} else {
166+
log.Ops.Errorf(ctx, "failed internal API [%s] %s - %v", req.Method, req.URL.Path, err)
167+
}
168+
169+
data := &serverpb.ResponseError{
170+
Error: message,
171+
Message: message,
172+
Code: int32(s.Code()),
173+
// Details field is intentionally not populated as it's unused
174+
}
175+
176+
// Convert gRPC status code to HTTP status code
177+
// TODO(server): eliminate this dependency on grpc-gateway when
178+
// migrating away from it
179+
httpCode := runtime.HTTPStatusFromCode(s.Code())
180+
181+
if err := writeResponse(ctx, w, req, httpCode, data); err != nil {
182+
log.Dev.Errorf(ctx, "failed to respond with error: %v", err)
183+
const fallback = `{"code": 13, "message": "failed to marshal error message"}`
184+
if _, err := io.WriteString(w, fallback); err != nil {
185+
log.Dev.Errorf(ctx, "failed to write fallback error: %v", err)
186+
}
187+
}
188+
}
189+
190+
// writeResponse writes a protobuf message as an HTTP response. It supports both
191+
// JSON and protobuf content types based on the request headers.
192+
func writeResponse(
193+
ctx context.Context,
194+
w http.ResponseWriter,
195+
req *http.Request,
196+
code int,
197+
payload protoutil.Message,
198+
) error {
199+
// Determine the response content type by checking Accept header first, then
200+
// falling back to Content-Type header. Default to JSON if neither specifies
201+
// a supported type.
202+
resContentType := selectContentType(append(
203+
req.Header[httputil.AcceptEncodingHeader],
204+
req.Header[httputil.ContentTypeHeader]...))
205+
206+
var buf []byte
207+
switch resContentType {
208+
case httputil.ProtoContentType:
209+
b, err := protoutil.Marshal(payload)
210+
if err != nil {
211+
return status.Errorf(codes.Internal, "failed to marshal the protobuf response: %v", err)
212+
}
213+
buf = b
214+
case httputil.JSONContentType, httputil.MIMEWildcard:
215+
jsonpb := &protoutil.JSONPb{
216+
EnumsAsInts: true,
217+
EmitDefaults: true,
218+
Indent: " ",
219+
}
220+
b, err := jsonpb.Marshal(payload)
221+
if err != nil {
222+
return status.Errorf(codes.Internal, "failed to marshal the JSON response: %v", err)
223+
}
224+
buf = b
225+
}
226+
227+
w.Header().Set("Content-Type", resContentType)
228+
w.WriteHeader(code)
229+
if _, err := w.Write(buf); err != nil {
230+
return status.Errorf(codes.Internal, "failed to write HTTP response: %v", err)
231+
}
232+
return nil
233+
}
234+
235+
// decodeRequest decodes the request body into a protobuf message. It supports
236+
// both JSON and protobuf content types, defaulting to JSON.
237+
func decodeRequest(req *http.Request, target protoutil.Message) error {
238+
if req.Body == nil {
239+
return nil
240+
}
241+
reqContentType := selectContentType(req.Header[httputil.ContentTypeHeader])
242+
switch reqContentType {
243+
case httputil.JSONContentType, httputil.MIMEWildcard:
244+
return jsonpb.Unmarshal(req.Body, target)
245+
case httputil.ProtoContentType:
246+
bytes, err := io.ReadAll(req.Body)
247+
if err != nil {
248+
return err
249+
}
250+
return protoutil.Unmarshal(bytes, target)
251+
default:
252+
return jsonpb.Unmarshal(req.Body, target)
253+
}
254+
}
255+
256+
// selectContentType chooses the appropriate content type from a list of
257+
// options. It prefers protobuf or JSON if available, defaulting to JSON if none
258+
// match.
259+
func selectContentType(contentTypes []string) string {
260+
for _, c := range contentTypes {
261+
switch c {
262+
case httputil.ProtoContentType:
263+
return httputil.ProtoContentType
264+
case httputil.JSONContentType, httputil.MIMEWildcard:
265+
return httputil.JSONContentType
266+
}
267+
}
268+
return httputil.JSONContentType
269+
}

0 commit comments

Comments
 (0)