mirror of https://github.com/hashicorp/consul
315 lines
9.1 KiB
Go
315 lines
9.1 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package http
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/metadata"
|
|
"google.golang.org/grpc/status"
|
|
"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"
|
|
)
|
|
|
|
const (
|
|
HeaderConsulToken = "x-consul-token"
|
|
HeaderConsistencyMode = "x-consul-consistency-mode"
|
|
)
|
|
|
|
// NewHandler creates a new HTTP handler for the resource service.
|
|
// httpPathPrefix is the prefix to be used for all HTTP endpoints. Should start with "/" and
|
|
// end without a trailing "/".
|
|
// client is the gRPC client to be used to communicate with the resource service.
|
|
// registry is the resource registry to be used to determine the resource types.
|
|
// parseToken is a function that will be called to parse the Consul token from the request.
|
|
func NewHandler(
|
|
httpPathPrefix string,
|
|
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() {
|
|
// List Endpoint
|
|
base := strings.ToLower(fmt.Sprintf("/%s/%s/%s", t.Type.Group, t.Type.GroupVersion, t.Type.Kind))
|
|
mux.Handle(base, http.StripPrefix(base, &listHandler{t, client, parseToken, logger}))
|
|
logger.Info("Registered resource endpoint", "endpoint", fmt.Sprintf("%s%s", httpPathPrefix, base))
|
|
|
|
// Individual Resource Endpoints
|
|
prefix := strings.ToLower(fmt.Sprintf("%s/", base))
|
|
mux.Handle(prefix, http.StripPrefix(prefix, &resourceHandler{t, client, parseToken, logger}))
|
|
}
|
|
|
|
return mux
|
|
}
|
|
|
|
type writeRequest struct {
|
|
Metadata map[string]string `json:"metadata"`
|
|
Data json.RawMessage `json:"data"`
|
|
Owner *pbresource.ID `json:"owner"`
|
|
}
|
|
|
|
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(), HeaderConsulToken, token)
|
|
switch r.Method {
|
|
case http.MethodPut:
|
|
h.handleWrite(w, r, ctx)
|
|
case http.MethodGet:
|
|
h.handleRead(w, r, ctx)
|
|
case http.MethodDelete:
|
|
h.handleDelete(w, r, ctx)
|
|
default:
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (h *resourceHandler) handleWrite(w http.ResponseWriter, r *http.Request, ctx context.Context) {
|
|
var req writeRequest
|
|
// convert req body to writeRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
h.logger.Error("Failed to decode request body", "error", err)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("Request body format is invalid"))
|
|
return
|
|
}
|
|
// convert data struct to proto message
|
|
data := h.reg.Proto.ProtoReflect().New().Interface()
|
|
if err := protojson.Unmarshal(req.Data, data); err != nil {
|
|
h.logger.Error("Failed to unmarshal to proto message", "error", err)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("Request body didn't follow the resource schema"))
|
|
return
|
|
}
|
|
// 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, params := parseParams(r)
|
|
|
|
rsp, err := h.client.Write(ctx, &pbresource.WriteRequest{
|
|
Resource: &pbresource.Resource{
|
|
Id: &pbresource.ID{
|
|
Type: h.reg.Type,
|
|
Tenancy: tenancyInfo,
|
|
Name: params["resourceName"],
|
|
},
|
|
Owner: req.Owner,
|
|
Version: params["version"],
|
|
Metadata: req.Metadata,
|
|
Data: anyProtoMsg,
|
|
},
|
|
})
|
|
if err != nil {
|
|
handleResponseError(err, w, h.logger)
|
|
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 (h *resourceHandler) handleRead(w http.ResponseWriter, r *http.Request, ctx context.Context) {
|
|
tenancyInfo, params := parseParams(r)
|
|
if params["consistent"] != "" {
|
|
ctx = metadata.AppendToOutgoingContext(ctx, "x-consul-consistency-mode", "consistent")
|
|
}
|
|
|
|
rsp, err := h.client.Read(ctx, &pbresource.ReadRequest{
|
|
Id: &pbresource.ID{
|
|
Type: h.reg.Type,
|
|
Tenancy: tenancyInfo,
|
|
Name: params["resourceName"],
|
|
},
|
|
})
|
|
if err != nil {
|
|
handleResponseError(err, w, h.logger)
|
|
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)
|
|
}
|
|
|
|
// Note: The HTTP endpoints do not accept UID since it is quite unlikely that the user will have access to it
|
|
func (h *resourceHandler) handleDelete(w http.ResponseWriter, r *http.Request, ctx context.Context) {
|
|
tenancyInfo, params := parseParams(r)
|
|
_, err := h.client.Delete(ctx, &pbresource.DeleteRequest{
|
|
Id: &pbresource.ID{
|
|
Type: h.reg.Type,
|
|
Tenancy: tenancyInfo,
|
|
Name: params["resourceName"],
|
|
},
|
|
Version: params["version"],
|
|
})
|
|
if err != nil {
|
|
handleResponseError(err, w, h.logger)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
w.Write([]byte("{}"))
|
|
}
|
|
|
|
func parseParams(r *http.Request) (tenancy *pbresource.Tenancy, params map[string]string) {
|
|
query := r.URL.Query()
|
|
namespace := query.Get("namespace")
|
|
if namespace == "" {
|
|
namespace = query.Get("ns")
|
|
}
|
|
|
|
tenancy = &pbresource.Tenancy{
|
|
Partition: query.Get("partition"),
|
|
Namespace: namespace,
|
|
}
|
|
|
|
// TODO(peering/v2) handle parsing peer tenancy
|
|
|
|
resourceName := path.Base(r.URL.Path)
|
|
if resourceName == "." || resourceName == "/" {
|
|
resourceName = ""
|
|
}
|
|
|
|
params = make(map[string]string)
|
|
params["resourceName"] = resourceName
|
|
params["version"] = query.Get("version")
|
|
params["namePrefix"] = query.Get("name_prefix")
|
|
// coming from command line
|
|
params["consistent"] = query.Get("RequireConsistent")
|
|
// coming from http client
|
|
if _, ok := query["consistent"]; ok {
|
|
params["consistent"] = "true"
|
|
}
|
|
|
|
return tenancy, params
|
|
}
|
|
|
|
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, "", " ")
|
|
}
|
|
|
|
func handleResponseError(err error, w http.ResponseWriter, logger hclog.Logger) {
|
|
if e, ok := status.FromError(err); ok {
|
|
switch e.Code() {
|
|
case codes.InvalidArgument:
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
logger.Info("User has mal-formed request", "error", err)
|
|
case codes.NotFound:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
logger.Info("Received error from resource service: Not found", "error", err)
|
|
case codes.PermissionDenied:
|
|
w.WriteHeader(http.StatusForbidden)
|
|
logger.Info("Received error from resource service: User not authenticated", "error", err)
|
|
case codes.Aborted:
|
|
w.WriteHeader(http.StatusConflict)
|
|
logger.Info("Received error from resource service: the request conflict with the current state of the target resource", "error", err)
|
|
default:
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
logger.Error("Received error from resource service", "error", err)
|
|
}
|
|
} else {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
logger.Error("Received error from resource service: not able to parse error returned", "error", err)
|
|
}
|
|
w.Write([]byte(err.Error()))
|
|
}
|
|
|
|
type listHandler struct {
|
|
reg resource.Registration
|
|
client pbresource.ResourceServiceClient
|
|
parseToken func(req *http.Request, token *string)
|
|
logger hclog.Logger
|
|
}
|
|
|
|
func (h *listHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var token string
|
|
h.parseToken(r, &token)
|
|
ctx := metadata.AppendToOutgoingContext(r.Context(), HeaderConsulToken, token)
|
|
|
|
tenancyInfo, params := parseParams(r)
|
|
if params["consistent"] == "true" {
|
|
ctx = metadata.AppendToOutgoingContext(ctx, HeaderConsistencyMode, "consistent")
|
|
}
|
|
|
|
rsp, err := h.client.List(ctx, &pbresource.ListRequest{
|
|
Type: h.reg.Type,
|
|
Tenancy: tenancyInfo,
|
|
NamePrefix: params["namePrefix"],
|
|
})
|
|
if err != nil {
|
|
handleResponseError(err, w, h.logger)
|
|
return
|
|
}
|
|
|
|
output := make([]json.RawMessage, len(rsp.Resources))
|
|
for idx, res := range rsp.Resources {
|
|
b, err := jsonMarshal(res)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
h.logger.Error("Failed to unmarshal GRPC resource response", "error", err)
|
|
return
|
|
}
|
|
output[idx] = b
|
|
}
|
|
|
|
b, err := json.MarshalIndent(struct {
|
|
Resources []json.RawMessage `json:"resources"`
|
|
}{output}, "", " ")
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
h.logger.Error("Failed to correctly format the list response", "error", err)
|
|
return
|
|
}
|
|
w.Write(b)
|
|
}
|