diff --git a/agent/http.go b/agent/http.go index 32010c343a6..31aaf68c201 100644 --- a/agent/http.go +++ b/agent/http.go @@ -36,6 +36,7 @@ import ( "github.com/hashicorp/consul/agent/structs" "github.com/hashicorp/consul/agent/uiserver" "github.com/hashicorp/consul/api" + resourcehttp "github.com/hashicorp/consul/internal/resource/http" "github.com/hashicorp/consul/lib" "github.com/hashicorp/consul/logging" "github.com/hashicorp/consul/proto/private/pbcommon" @@ -259,6 +260,17 @@ func (s *HTTPHandlers) handler() http.Handler { handlePProf("/debug/pprof/symbol", pprof.Symbol) handlePProf("/debug/pprof/trace", pprof.Trace) + mux.Handle("/api/", + http.StripPrefix("/api", + resourcehttp.NewHandler( + s.agent.delegate.ResourceServiceClient(), + s.agent.baseDeps.Registry, + s.parseToken, + s.agent.logger.Named(logging.HTTP), + ), + ), + ) + if s.IsUIEnabled() { // Note that we _don't_ support reloading ui_config.{enabled, content_dir, // content_path} since this only runs at initial startup. diff --git a/internal/resource/http/http.go b/internal/resource/http/http.go new file mode 100644 index 00000000000..161a859f73b --- /dev/null +++ b/internal/resource/http/http.go @@ -0,0 +1,182 @@ +package http + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "path" + "strings" + + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/anypb" + + "github.com/hashicorp/go-hclog" + + "github.com/hashicorp/consul/internal/resource" + "github.com/hashicorp/consul/proto-public/pbresource" +) + +func NewHandler( + client pbresource.ResourceServiceClient, + registry resource.Registry, + parseToken func(req *http.Request, token *string), + logger hclog.Logger) http.Handler { + mux := http.NewServeMux() + for _, t := range registry.Types() { + // Individual Resource Endpoints. + prefix := strings.ToLower(fmt.Sprintf("/%s/%s/%s/", t.Type.Group, t.Type.GroupVersion, t.Type.Kind)) + logger.Info("Registered resource endpoint", "endpoint", prefix) + mux.Handle(prefix, http.StripPrefix(prefix, &resourceHandler{t, client, parseToken, logger})) + } + + return mux +} + +type writeRequest struct { + // TODO: Owner. + Version string `json:"version"` + Metadata map[string]string `json:"metadata"` + Data json.RawMessage `json:"data"` +} + +type resourceHandler struct { + reg resource.Registration + client pbresource.ResourceServiceClient + parseToken func(req *http.Request, token *string) + logger hclog.Logger +} + +func (h *resourceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + var token string + h.parseToken(r, &token) + ctx := metadata.AppendToOutgoingContext(r.Context(), "x-consul-token", token) + switch r.Method { + case http.MethodGet: + h.handleRead(w, r, ctx) + case http.MethodPut: + h.handleWrite(w, r, ctx) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + return + } +} + +func (h *resourceHandler) handleRead(w http.ResponseWriter, r *http.Request, ctx context.Context) { + tenancyInfo, _ := checkURL(r) + if tenancyInfo == nil { + w.WriteHeader(http.StatusBadRequest) + return + } + rsp, err := h.client.Read(ctx, &pbresource.ReadRequest{ + Id: &pbresource.ID{ + Type: h.reg.Type, + Tenancy: tenancyInfo, + Name: r.URL.Path, + }, + }) + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + output, err := jsonMarshal(rsp.Resource) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Write(output) +} + +func (h *resourceHandler) handleWrite(w http.ResponseWriter, r *http.Request, ctx context.Context) { + var req writeRequest + // convert req data to struct + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Request body didn't follow schema.")) + } + // struct to proto message + data := h.reg.Proto.ProtoReflect().New().Interface() + if err := protojson.Unmarshal(req.Data, data); err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Request body didn't follow schema.")) + } + // proto message to any + anyProtoMsg, err := anypb.New(data) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + h.logger.Error("Failed to convert proto message to any type", "error", err) + return + } + + tenancyInfo, resourceName := checkURL(r) + if tenancyInfo == nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Missing partition, peer_name or namespace in the query params")) + } + if resourceName == "" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Missing resource name in the URL")) + } + rsp, err := h.client.Write(ctx, &pbresource.WriteRequest{ + Resource: &pbresource.Resource{ + Id: &pbresource.ID{ + Type: h.reg.Type, + Tenancy: tenancyInfo, + Name: resourceName, + }, + Version: req.Version, + Metadata: req.Metadata, + Data: anyProtoMsg, + }, + }) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + h.logger.Error("Failed to write to GRPC resource", "error", err) + return + } + + output, err := jsonMarshal(rsp.Resource) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + h.logger.Error("Failed to unmarshal GRPC resource response", "error", err) + return + } + w.Write(output) +} + +func checkURL(r *http.Request) (tenancy *pbresource.Tenancy, resourceName string) { + params := r.URL.Query() + partition := params.Get("partition") + peerName := params.Get("peer_name") + namespace := params.Get("namespace") + if partition == "" || peerName == "" || namespace == "" { + tenancy = nil + } else { + tenancy = &pbresource.Tenancy{ + Partition: partition, + PeerName: peerName, + Namespace: namespace, + } + } + resourceName = path.Base(r.URL.Path) + + return +} + +func jsonMarshal(res *pbresource.Resource) ([]byte, error) { + output, err := protojson.Marshal(res) + if err != nil { + return nil, err + } + + var stuff map[string]any + if err := json.Unmarshal(output, &stuff); err != nil { + return nil, err + } + + delete(stuff["data"].(map[string]any), "@type") + return json.MarshalIndent(stuff, "", " ") +} diff --git a/internal/resource/http/http_test.go b/internal/resource/http/http_test.go new file mode 100644 index 00000000000..6bc9be9a2cf --- /dev/null +++ b/internal/resource/http/http_test.go @@ -0,0 +1,70 @@ +package http + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/require" + + svctest "github.com/hashicorp/consul/agent/grpc-external/services/resource/testing" + "github.com/hashicorp/consul/internal/resource" + "github.com/hashicorp/consul/internal/resource/demo" + "github.com/hashicorp/consul/proto-public/pbresource" + pbdemov2 "github.com/hashicorp/consul/proto/private/pbdemo/v2" + "github.com/hashicorp/consul/sdk/testutil" +) + +func TestResourceHandler(t *testing.T) { + client := svctest.RunResourceService(t, demo.RegisterTypes) + + resourceHandler := resourceHandler{ + resource.Registration{ + Type: demo.TypeV2Artist, + Proto: &pbdemov2.Artist{}, + }, + client, + func(req *http.Request, token *string) { return }, + hclog.NewNullLogger(), + } + + t.Run("Write", func(t *testing.T) { + rsp := httptest.NewRecorder() + req := httptest.NewRequest("PUT", "/api/demo/v2/artist/keith-urban?partition=default&peer_name=local&namespace=default", strings.NewReader(` + { + "metadata": { + "foo": "bar" + }, + "data": { + "name": "Keith Urban", + "genre": "GENRE_COUNTRY" + } + } + `)) + + resourceHandler.ServeHTTP(rsp, req) + + require.Equal(t, http.StatusOK, rsp.Result().StatusCode) + + var result map[string]any + require.NoError(t, json.NewDecoder(rsp.Body).Decode(&result)) + require.Equal(t, "Keith Urban", result["data"].(map[string]any)["name"]) + + readRsp, err := client.Read(testutil.TestContext(t), &pbresource.ReadRequest{ + Id: &pbresource.ID{ + Type: demo.TypeV2Artist, + Tenancy: demo.TenancyDefault, + Name: "keith-urban", + }, + }) + require.NoError(t, err) + require.NotNil(t, readRsp.Resource) + + var artist pbdemov2.Artist + require.NoError(t, readRsp.Resource.Data.UnmarshalTo(&artist)) + require.Equal(t, "Keith Urban", artist.Name) + }) +} diff --git a/internal/resource/registry.go b/internal/resource/registry.go index 0004acfff4c..4f06c6faa10 100644 --- a/internal/resource/registry.go +++ b/internal/resource/registry.go @@ -26,6 +26,8 @@ type Registry interface { // Resolve the given resource type and its hooks. Resolve(typ *pbresource.Type) (reg Registration, ok bool) + + Types() []Registration } type Registration struct { @@ -154,6 +156,17 @@ func (r *TypeRegistry) Resolve(typ *pbresource.Type) (reg Registration, ok bool) return Registration{}, false } +func (r *TypeRegistry) Types() []Registration { + r.lock.RLock() + defer r.lock.RUnlock() + + types := make([]Registration, 0, len(r.registrations)) + for _, v := range r.registrations { + types = append(types, v) + } + return types +} + func ToGVK(resourceType *pbresource.Type) string { return fmt.Sprintf("%s.%s.%s", resourceType.Group, resourceType.GroupVersion, resourceType.Kind) }