diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f8fd642fa81..3d16e79ca0b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,6 +4,7 @@ /docker/ @derekperkins @dkhenry /examples/compose @shlomi-noach /examples/local @rohit-nayak-ps +/go/cmd/vtadmin @ajm188 @doeg /go/cmd/vtctldclient @ajm188 @doeg /go/mysql @harshit-gangal @systay /go/test/endtoend/onlineddl @shlomi-noach @@ -14,6 +15,7 @@ /go/vt/orchestrator @deepthi @shlomi-noach /go/vt/schema @shlomi-noach /go/vt/sqlparser @harshit-gangal @systay +/go/vt/vtadmin @ajm188 @doeg /go/vt/vtctl @deepthi /go/vt/vtctl/vtctl.go @ajm188 @doeg /go/vt/vtctl/grpcvtctldclient @ajm188 @doeg diff --git a/go.mod b/go.mod index d5e6020f8da..ef433a786c5 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,8 @@ require ( github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.1.1 github.com/googleapis/gnostic v0.2.0 // indirect + github.com/gorilla/handlers v1.5.1 + github.com/gorilla/mux v1.8.0 github.com/gorilla/websocket v1.4.2 github.com/grpc-ecosystem/go-grpc-middleware v1.1.0 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 @@ -80,7 +82,9 @@ require ( github.com/samuel/go-zookeeper v0.0.0-20200724154423-2164a8ac840e github.com/satori/go.uuid v1.2.0 // indirect github.com/sjmudd/stopwatch v0.0.0-20170613150411-f380bf8a9be1 + github.com/soheilhy/cmux v0.1.4 github.com/spf13/cobra v1.1.1 + github.com/spf13/pflag v1.0.5 github.com/spyzhov/ajson v0.4.2 github.com/stretchr/testify v1.4.0 github.com/tchap/go-patricia v0.0.0-20160729071656-dd168db6051b @@ -105,6 +109,7 @@ require ( gopkg.in/gcfg.v1 v1.2.3 gopkg.in/ldap.v2 v2.5.0 gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v2 v2.4.0 gotest.tools v2.2.0+incompatible honnef.co/go/tools v0.0.1-2019.2.3 k8s.io/apiextensions-apiserver v0.17.3 diff --git a/go.sum b/go.sum index 6b1485cb833..d2c3874432b 100644 --- a/go.sum +++ b/go.sum @@ -175,6 +175,8 @@ github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= +github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -296,6 +298,10 @@ github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEo github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= +github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= @@ -935,6 +941,8 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/go/cmd/vtadmin/main.go b/go/cmd/vtadmin/main.go new file mode 100644 index 00000000000..3ec8e8cb836 --- /dev/null +++ b/go/cmd/vtadmin/main.go @@ -0,0 +1,100 @@ +/* +Copyright 2020 The Vitess Authors. + +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 ( + "flag" + "os" + "time" + + "github.com/spf13/cobra" + "vitess.io/vitess/go/vt/log" + "vitess.io/vitess/go/vt/vtadmin" + "vitess.io/vitess/go/vt/vtadmin/cluster" + "vitess.io/vitess/go/vt/vtadmin/grpcserver" + vtadminhttp "vitess.io/vitess/go/vt/vtadmin/http" +) + +var ( + opts grpcserver.Options + httpOpts vtadminhttp.Options + clusterConfigs cluster.ClustersFlag + clusterFileConfig cluster.FileConfig + defaultClusterConfig cluster.Config + + rootCmd = &cobra.Command{ + Use: "vtadmin", + PreRun: func(cmd *cobra.Command, args []string) { + tmp := os.Args + os.Args = os.Args[0:1] + flag.Parse() + os.Args = tmp + // (TODO:@amason) Check opts.EnableTracing and trace boot time. + }, + Run: run, + } +) + +func run(cmd *cobra.Command, args []string) { + configs := clusterFileConfig.Combine(defaultClusterConfig, clusterConfigs) + clusters := make([]*cluster.Cluster, len(configs)) + + if len(configs) == 0 { + log.Fatal("must specify at least one cluster") + } + + for i, cfg := range configs { + cluster, err := cfg.Cluster() + if err != nil { + log.Fatal(err) + } + + clusters[i] = cluster + } + + s := vtadmin.NewAPI(clusters, opts, httpOpts) + if err := s.ListenAndServe(); err != nil { + log.Fatal(err) + } +} + +func main() { + rootCmd.Flags().StringVar(&opts.Addr, "addr", ":15000", "address to serve on") + rootCmd.Flags().DurationVar(&opts.CMuxReadTimeout, "lmux-read-timeout", time.Second, "how long to spend connection muxing") + rootCmd.Flags().DurationVar(&opts.LameDuckDuration, "lame-duck-duration", time.Second*5, "length of lame duck period at shutdown") + rootCmd.Flags().Var(&clusterConfigs, "cluster", "per-cluster configuration. any values here take precedence over those in -cluster-defaults or -cluster-config") + rootCmd.Flags().Var(&clusterFileConfig, "cluster-config", "path to a yaml cluster configuration. see clusters.example.yaml") // (TODO:@amason) provide example config. + rootCmd.Flags().Var(&defaultClusterConfig, "cluster-defaults", "default options for all clusters") + + rootCmd.Flags().BoolVar(&opts.EnableTracing, "grpc-tracing", false, "whether to enable tracing on the gRPC server") + rootCmd.Flags().BoolVar(&httpOpts.EnableTracing, "http-tracing", false, "whether to enable tracing on the HTTP server") + rootCmd.Flags().BoolVar(&httpOpts.DisableCompression, "http-no-compress", false, "whether to disable compression of HTTP API responses") + rootCmd.Flags().StringSliceVar(&httpOpts.CORSOrigins, "http-origin", []string{}, "repeated, comma-separated flag of allowed CORS origins. omit to disable CORS") + + // glog flags, no better way to do this + rootCmd.Flags().AddGoFlag(flag.Lookup("v")) + rootCmd.Flags().AddGoFlag(flag.Lookup("logtostderr")) + rootCmd.Flags().AddGoFlag(flag.Lookup("alsologtostderr")) + rootCmd.Flags().AddGoFlag(flag.Lookup("stderrthreshold")) + rootCmd.Flags().AddGoFlag(flag.Lookup("log_dir")) + + if err := rootCmd.Execute(); err != nil { + log.Fatal(err) + } + + log.Flush() +} diff --git a/go/vt/proto/vtadmin/vtadmin.pb.go b/go/vt/proto/vtadmin/vtadmin.pb.go new file mode 100644 index 00000000000..aa926bba944 --- /dev/null +++ b/go/vt/proto/vtadmin/vtadmin.pb.go @@ -0,0 +1,653 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// source: vtadmin.proto + +package vtadmin + +import ( + context "context" + fmt "fmt" + math "math" + + proto "github.com/golang/protobuf/proto" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + topodata "vitess.io/vitess/go/vt/proto/topodata" +) + +// Reference imports to suppress errors if they are not otherwise used. +var _ = proto.Marshal +var _ = fmt.Errorf +var _ = math.Inf + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the proto package it is being compiled against. +// A compilation error at this line likely means your copy of the +// proto package needs to be updated. +const _ = proto.ProtoPackageIsVersion3 // please upgrade the proto package + +type Tablet_ServingState int32 + +const ( + Tablet_UNKNOWN Tablet_ServingState = 0 + Tablet_SERVING Tablet_ServingState = 1 + Tablet_NOT_SERVING Tablet_ServingState = 2 +) + +var Tablet_ServingState_name = map[int32]string{ + 0: "UNKNOWN", + 1: "SERVING", + 2: "NOT_SERVING", +} + +var Tablet_ServingState_value = map[string]int32{ + "UNKNOWN": 0, + "SERVING": 1, + "NOT_SERVING": 2, +} + +func (x Tablet_ServingState) String() string { + return proto.EnumName(Tablet_ServingState_name, int32(x)) +} + +func (Tablet_ServingState) EnumDescriptor() ([]byte, []int) { + return fileDescriptor_609739e22a0a50b3, []int{1, 0} +} + +// Cluster represents information about a Vitess cluster. +type Cluster struct { + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *Cluster) Reset() { *m = Cluster{} } +func (m *Cluster) String() string { return proto.CompactTextString(m) } +func (*Cluster) ProtoMessage() {} +func (*Cluster) Descriptor() ([]byte, []int) { + return fileDescriptor_609739e22a0a50b3, []int{0} +} + +func (m *Cluster) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_Cluster.Unmarshal(m, b) +} +func (m *Cluster) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_Cluster.Marshal(b, m, deterministic) +} +func (m *Cluster) XXX_Merge(src proto.Message) { + xxx_messageInfo_Cluster.Merge(m, src) +} +func (m *Cluster) XXX_Size() int { + return xxx_messageInfo_Cluster.Size(m) +} +func (m *Cluster) XXX_DiscardUnknown() { + xxx_messageInfo_Cluster.DiscardUnknown(m) +} + +var xxx_messageInfo_Cluster proto.InternalMessageInfo + +func (m *Cluster) GetId() string { + if m != nil { + return m.Id + } + return "" +} + +func (m *Cluster) GetName() string { + if m != nil { + return m.Name + } + return "" +} + +// Tablet groups the topo information of a tablet together with the Vitess +// cluster it belongs to. +type Tablet struct { + Cluster *Cluster `protobuf:"bytes,1,opt,name=cluster,proto3" json:"cluster,omitempty"` + Tablet *topodata.Tablet `protobuf:"bytes,2,opt,name=tablet,proto3" json:"tablet,omitempty"` + State Tablet_ServingState `protobuf:"varint,3,opt,name=state,proto3,enum=vtadmin.Tablet_ServingState" json:"state,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *Tablet) Reset() { *m = Tablet{} } +func (m *Tablet) String() string { return proto.CompactTextString(m) } +func (*Tablet) ProtoMessage() {} +func (*Tablet) Descriptor() ([]byte, []int) { + return fileDescriptor_609739e22a0a50b3, []int{1} +} + +func (m *Tablet) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_Tablet.Unmarshal(m, b) +} +func (m *Tablet) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_Tablet.Marshal(b, m, deterministic) +} +func (m *Tablet) XXX_Merge(src proto.Message) { + xxx_messageInfo_Tablet.Merge(m, src) +} +func (m *Tablet) XXX_Size() int { + return xxx_messageInfo_Tablet.Size(m) +} +func (m *Tablet) XXX_DiscardUnknown() { + xxx_messageInfo_Tablet.DiscardUnknown(m) +} + +var xxx_messageInfo_Tablet proto.InternalMessageInfo + +func (m *Tablet) GetCluster() *Cluster { + if m != nil { + return m.Cluster + } + return nil +} + +func (m *Tablet) GetTablet() *topodata.Tablet { + if m != nil { + return m.Tablet + } + return nil +} + +func (m *Tablet) GetState() Tablet_ServingState { + if m != nil { + return m.State + } + return Tablet_UNKNOWN +} + +// VTGate represents information about a single VTGate host. +type VTGate struct { + // Hostname is the shortname of the VTGate. + Hostname string `protobuf:"bytes,1,opt,name=hostname,proto3" json:"hostname,omitempty"` + // Pool is group the VTGate serves queries for. Some deployments segment + // VTGates into groups or pools, based on the workloads they serve queries + // for. Use of this field is optional. + Pool string `protobuf:"bytes,2,opt,name=pool,proto3" json:"pool,omitempty"` + // Cell is the topology cell the VTGate is in. + Cell string `protobuf:"bytes,3,opt,name=cell,proto3" json:"cell,omitempty"` + // Cluster is the name of the cluster the VTGate serves. + Cluster string `protobuf:"bytes,4,opt,name=cluster,proto3" json:"cluster,omitempty"` + // Keyspaces is the list of keyspaces-to-watch for the VTGate. + Keyspaces []string `protobuf:"bytes,5,rep,name=keyspaces,proto3" json:"keyspaces,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *VTGate) Reset() { *m = VTGate{} } +func (m *VTGate) String() string { return proto.CompactTextString(m) } +func (*VTGate) ProtoMessage() {} +func (*VTGate) Descriptor() ([]byte, []int) { + return fileDescriptor_609739e22a0a50b3, []int{2} +} + +func (m *VTGate) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_VTGate.Unmarshal(m, b) +} +func (m *VTGate) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_VTGate.Marshal(b, m, deterministic) +} +func (m *VTGate) XXX_Merge(src proto.Message) { + xxx_messageInfo_VTGate.Merge(m, src) +} +func (m *VTGate) XXX_Size() int { + return xxx_messageInfo_VTGate.Size(m) +} +func (m *VTGate) XXX_DiscardUnknown() { + xxx_messageInfo_VTGate.DiscardUnknown(m) +} + +var xxx_messageInfo_VTGate proto.InternalMessageInfo + +func (m *VTGate) GetHostname() string { + if m != nil { + return m.Hostname + } + return "" +} + +func (m *VTGate) GetPool() string { + if m != nil { + return m.Pool + } + return "" +} + +func (m *VTGate) GetCell() string { + if m != nil { + return m.Cell + } + return "" +} + +func (m *VTGate) GetCluster() string { + if m != nil { + return m.Cluster + } + return "" +} + +func (m *VTGate) GetKeyspaces() []string { + if m != nil { + return m.Keyspaces + } + return nil +} + +type GetGatesRequest struct { + ClusterIds []string `protobuf:"bytes,1,rep,name=cluster_ids,json=clusterIds,proto3" json:"cluster_ids,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *GetGatesRequest) Reset() { *m = GetGatesRequest{} } +func (m *GetGatesRequest) String() string { return proto.CompactTextString(m) } +func (*GetGatesRequest) ProtoMessage() {} +func (*GetGatesRequest) Descriptor() ([]byte, []int) { + return fileDescriptor_609739e22a0a50b3, []int{3} +} + +func (m *GetGatesRequest) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_GetGatesRequest.Unmarshal(m, b) +} +func (m *GetGatesRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_GetGatesRequest.Marshal(b, m, deterministic) +} +func (m *GetGatesRequest) XXX_Merge(src proto.Message) { + xxx_messageInfo_GetGatesRequest.Merge(m, src) +} +func (m *GetGatesRequest) XXX_Size() int { + return xxx_messageInfo_GetGatesRequest.Size(m) +} +func (m *GetGatesRequest) XXX_DiscardUnknown() { + xxx_messageInfo_GetGatesRequest.DiscardUnknown(m) +} + +var xxx_messageInfo_GetGatesRequest proto.InternalMessageInfo + +func (m *GetGatesRequest) GetClusterIds() []string { + if m != nil { + return m.ClusterIds + } + return nil +} + +type GetGatesResponse struct { + Gates []*VTGate `protobuf:"bytes,1,rep,name=gates,proto3" json:"gates,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *GetGatesResponse) Reset() { *m = GetGatesResponse{} } +func (m *GetGatesResponse) String() string { return proto.CompactTextString(m) } +func (*GetGatesResponse) ProtoMessage() {} +func (*GetGatesResponse) Descriptor() ([]byte, []int) { + return fileDescriptor_609739e22a0a50b3, []int{4} +} + +func (m *GetGatesResponse) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_GetGatesResponse.Unmarshal(m, b) +} +func (m *GetGatesResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_GetGatesResponse.Marshal(b, m, deterministic) +} +func (m *GetGatesResponse) XXX_Merge(src proto.Message) { + xxx_messageInfo_GetGatesResponse.Merge(m, src) +} +func (m *GetGatesResponse) XXX_Size() int { + return xxx_messageInfo_GetGatesResponse.Size(m) +} +func (m *GetGatesResponse) XXX_DiscardUnknown() { + xxx_messageInfo_GetGatesResponse.DiscardUnknown(m) +} + +var xxx_messageInfo_GetGatesResponse proto.InternalMessageInfo + +func (m *GetGatesResponse) GetGates() []*VTGate { + if m != nil { + return m.Gates + } + return nil +} + +type GetTabletRequest struct { + Hostname string `protobuf:"bytes,1,opt,name=hostname,proto3" json:"hostname,omitempty"` + // ClusterIDs is an optional parameter to narrow the scope of the search, if + // the caller knows which cluster the tablet may be in, or, to disamiguate if + // multiple clusters have a tablet with the same hostname. + ClusterIds []string `protobuf:"bytes,2,rep,name=cluster_ids,json=clusterIds,proto3" json:"cluster_ids,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *GetTabletRequest) Reset() { *m = GetTabletRequest{} } +func (m *GetTabletRequest) String() string { return proto.CompactTextString(m) } +func (*GetTabletRequest) ProtoMessage() {} +func (*GetTabletRequest) Descriptor() ([]byte, []int) { + return fileDescriptor_609739e22a0a50b3, []int{5} +} + +func (m *GetTabletRequest) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_GetTabletRequest.Unmarshal(m, b) +} +func (m *GetTabletRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_GetTabletRequest.Marshal(b, m, deterministic) +} +func (m *GetTabletRequest) XXX_Merge(src proto.Message) { + xxx_messageInfo_GetTabletRequest.Merge(m, src) +} +func (m *GetTabletRequest) XXX_Size() int { + return xxx_messageInfo_GetTabletRequest.Size(m) +} +func (m *GetTabletRequest) XXX_DiscardUnknown() { + xxx_messageInfo_GetTabletRequest.DiscardUnknown(m) +} + +var xxx_messageInfo_GetTabletRequest proto.InternalMessageInfo + +func (m *GetTabletRequest) GetHostname() string { + if m != nil { + return m.Hostname + } + return "" +} + +func (m *GetTabletRequest) GetClusterIds() []string { + if m != nil { + return m.ClusterIds + } + return nil +} + +type GetTabletsRequest struct { + ClusterIds []string `protobuf:"bytes,1,rep,name=cluster_ids,json=clusterIds,proto3" json:"cluster_ids,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *GetTabletsRequest) Reset() { *m = GetTabletsRequest{} } +func (m *GetTabletsRequest) String() string { return proto.CompactTextString(m) } +func (*GetTabletsRequest) ProtoMessage() {} +func (*GetTabletsRequest) Descriptor() ([]byte, []int) { + return fileDescriptor_609739e22a0a50b3, []int{6} +} + +func (m *GetTabletsRequest) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_GetTabletsRequest.Unmarshal(m, b) +} +func (m *GetTabletsRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_GetTabletsRequest.Marshal(b, m, deterministic) +} +func (m *GetTabletsRequest) XXX_Merge(src proto.Message) { + xxx_messageInfo_GetTabletsRequest.Merge(m, src) +} +func (m *GetTabletsRequest) XXX_Size() int { + return xxx_messageInfo_GetTabletsRequest.Size(m) +} +func (m *GetTabletsRequest) XXX_DiscardUnknown() { + xxx_messageInfo_GetTabletsRequest.DiscardUnknown(m) +} + +var xxx_messageInfo_GetTabletsRequest proto.InternalMessageInfo + +func (m *GetTabletsRequest) GetClusterIds() []string { + if m != nil { + return m.ClusterIds + } + return nil +} + +type GetTabletsResponse struct { + Tablets []*Tablet `protobuf:"bytes,1,rep,name=tablets,proto3" json:"tablets,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *GetTabletsResponse) Reset() { *m = GetTabletsResponse{} } +func (m *GetTabletsResponse) String() string { return proto.CompactTextString(m) } +func (*GetTabletsResponse) ProtoMessage() {} +func (*GetTabletsResponse) Descriptor() ([]byte, []int) { + return fileDescriptor_609739e22a0a50b3, []int{7} +} + +func (m *GetTabletsResponse) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_GetTabletsResponse.Unmarshal(m, b) +} +func (m *GetTabletsResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_GetTabletsResponse.Marshal(b, m, deterministic) +} +func (m *GetTabletsResponse) XXX_Merge(src proto.Message) { + xxx_messageInfo_GetTabletsResponse.Merge(m, src) +} +func (m *GetTabletsResponse) XXX_Size() int { + return xxx_messageInfo_GetTabletsResponse.Size(m) +} +func (m *GetTabletsResponse) XXX_DiscardUnknown() { + xxx_messageInfo_GetTabletsResponse.DiscardUnknown(m) +} + +var xxx_messageInfo_GetTabletsResponse proto.InternalMessageInfo + +func (m *GetTabletsResponse) GetTablets() []*Tablet { + if m != nil { + return m.Tablets + } + return nil +} + +func init() { + proto.RegisterEnum("vtadmin.Tablet_ServingState", Tablet_ServingState_name, Tablet_ServingState_value) + proto.RegisterType((*Cluster)(nil), "vtadmin.Cluster") + proto.RegisterType((*Tablet)(nil), "vtadmin.Tablet") + proto.RegisterType((*VTGate)(nil), "vtadmin.VTGate") + proto.RegisterType((*GetGatesRequest)(nil), "vtadmin.GetGatesRequest") + proto.RegisterType((*GetGatesResponse)(nil), "vtadmin.GetGatesResponse") + proto.RegisterType((*GetTabletRequest)(nil), "vtadmin.GetTabletRequest") + proto.RegisterType((*GetTabletsRequest)(nil), "vtadmin.GetTabletsRequest") + proto.RegisterType((*GetTabletsResponse)(nil), "vtadmin.GetTabletsResponse") +} + +func init() { proto.RegisterFile("vtadmin.proto", fileDescriptor_609739e22a0a50b3) } + +var fileDescriptor_609739e22a0a50b3 = []byte{ + // 473 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x8c, 0x53, 0x5d, 0x8b, 0xd3, 0x40, + 0x14, 0x6d, 0xb2, 0xdb, 0x66, 0x73, 0xa3, 0x6d, 0xbd, 0x4f, 0x31, 0x2e, 0x58, 0x82, 0x4a, 0x15, + 0x6c, 0x20, 0xfa, 0xd2, 0x27, 0x59, 0x45, 0xca, 0x22, 0xa4, 0x30, 0xad, 0x15, 0x7c, 0x59, 0xb2, + 0xcd, 0x50, 0x83, 0xd9, 0x4e, 0xec, 0xcc, 0x16, 0x7c, 0xf7, 0xef, 0x09, 0xfe, 0x24, 0x99, 0x8f, + 0x4c, 0x77, 0xdb, 0x22, 0xbe, 0xdd, 0x7b, 0xee, 0x3d, 0x77, 0xce, 0x39, 0x24, 0xf0, 0x70, 0x2b, + 0xf2, 0xe2, 0xa6, 0x5c, 0x8f, 0xea, 0x0d, 0x13, 0x0c, 0x3d, 0xd3, 0x46, 0x5d, 0xc1, 0x6a, 0x56, + 0xe4, 0x22, 0xd7, 0x83, 0xf8, 0x35, 0x78, 0x1f, 0xaa, 0x5b, 0x2e, 0xe8, 0x06, 0xbb, 0xe0, 0x96, + 0x45, 0xe8, 0x0c, 0x9c, 0xa1, 0x4f, 0xdc, 0xb2, 0x40, 0x84, 0xd3, 0x75, 0x7e, 0x43, 0x43, 0x57, + 0x21, 0xaa, 0x8e, 0x7f, 0x3b, 0xd0, 0x99, 0xe7, 0xd7, 0x15, 0x15, 0xf8, 0x0a, 0xbc, 0xa5, 0x66, + 0x2a, 0x4e, 0x90, 0xf6, 0x47, 0xcd, 0x9b, 0xe6, 0x22, 0x69, 0x16, 0x70, 0x08, 0x1d, 0xa1, 0x58, + 0xea, 0x98, 0x5c, 0xb5, 0x32, 0xf4, 0x35, 0x62, 0xe6, 0x98, 0x42, 0x9b, 0x8b, 0x5c, 0xd0, 0xf0, + 0x64, 0xe0, 0x0c, 0xbb, 0xe9, 0xb9, 0xbd, 0xa9, 0xf7, 0x46, 0x33, 0xba, 0xd9, 0x96, 0xeb, 0xd5, + 0x4c, 0xee, 0x10, 0xbd, 0x1a, 0x8f, 0xe1, 0xc1, 0x5d, 0x18, 0x03, 0xf0, 0x3e, 0x67, 0x9f, 0xb2, + 0xe9, 0x97, 0xac, 0xdf, 0x92, 0xcd, 0xec, 0x23, 0x59, 0x5c, 0x66, 0x93, 0xbe, 0x83, 0x3d, 0x08, + 0xb2, 0xe9, 0xfc, 0xaa, 0x01, 0xdc, 0xf8, 0x97, 0x03, 0x9d, 0xc5, 0x7c, 0x22, 0x59, 0x11, 0x9c, + 0x7d, 0x63, 0x5c, 0x28, 0xcb, 0x3a, 0x04, 0xdb, 0xcb, 0x28, 0x6a, 0xc6, 0xaa, 0x26, 0x0a, 0x59, + 0x4b, 0x6c, 0x49, 0xab, 0x4a, 0x09, 0xf5, 0x89, 0xaa, 0x31, 0xdc, 0x65, 0x72, 0xaa, 0x60, 0x9b, + 0xc0, 0x39, 0xf8, 0xdf, 0xe9, 0x4f, 0x5e, 0xe7, 0x4b, 0xca, 0xc3, 0xf6, 0xe0, 0x64, 0xe8, 0x93, + 0x1d, 0x10, 0xa7, 0xd0, 0x9b, 0x50, 0x21, 0x65, 0x70, 0x42, 0x7f, 0xdc, 0x52, 0x2e, 0xf0, 0x29, + 0x04, 0x86, 0x7b, 0x55, 0x16, 0x3c, 0x74, 0x14, 0x05, 0x0c, 0x74, 0x59, 0xf0, 0x78, 0x0c, 0xfd, + 0x1d, 0x87, 0xd7, 0x6c, 0xcd, 0x29, 0x3e, 0x87, 0xf6, 0x4a, 0x02, 0x6a, 0x3d, 0x48, 0x7b, 0x36, + 0x3d, 0xed, 0x91, 0xe8, 0x69, 0x3c, 0x55, 0x54, 0x93, 0xbc, 0x79, 0xef, 0x5f, 0xf6, 0xf7, 0xb4, + 0xb8, 0x07, 0x5a, 0xde, 0xc2, 0x23, 0x7b, 0xf0, 0xff, 0x1d, 0xbc, 0x03, 0xbc, 0xcb, 0x32, 0x1e, + 0x5e, 0x82, 0xa7, 0xbf, 0x85, 0x43, 0x17, 0x46, 0x71, 0x33, 0x4f, 0xff, 0x38, 0xe0, 0x2d, 0xe6, + 0x17, 0x72, 0x86, 0x17, 0x70, 0xd6, 0xc4, 0x81, 0xa1, 0x65, 0xec, 0xa5, 0x1a, 0x3d, 0x3e, 0x32, + 0xd1, 0xef, 0xc6, 0x2d, 0x1c, 0x83, 0x6f, 0xf5, 0xe0, 0xbd, 0xcd, 0x7b, 0x51, 0x45, 0xfb, 0x82, + 0xe2, 0x16, 0x4e, 0x00, 0x76, 0x56, 0x30, 0x3a, 0xe4, 0x5a, 0x05, 0x4f, 0x8e, 0xce, 0x1a, 0x0d, + 0xef, 0x5f, 0x7c, 0x7d, 0xb6, 0x2d, 0x05, 0xe5, 0x7c, 0x54, 0xb2, 0x44, 0x57, 0xc9, 0x8a, 0x25, + 0x5b, 0x91, 0xa8, 0xff, 0x35, 0x31, 0xe4, 0xeb, 0x8e, 0x6a, 0xdf, 0xfc, 0x0d, 0x00, 0x00, 0xff, + 0xff, 0x51, 0x4b, 0xaa, 0x23, 0xe8, 0x03, 0x00, 0x00, +} + +// Reference imports to suppress errors if they are not otherwise used. +var _ context.Context +var _ grpc.ClientConn + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +const _ = grpc.SupportPackageIsVersion4 + +// VTAdminClient is the client API for VTAdmin service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream. +type VTAdminClient interface { + // GetGates returns all gates across all the specified clusters. + GetGates(ctx context.Context, in *GetGatesRequest, opts ...grpc.CallOption) (*GetGatesResponse, error) + // GetTablet looks up a tablet by hostname across all clusters and returns + // the result. + GetTablet(ctx context.Context, in *GetTabletRequest, opts ...grpc.CallOption) (*Tablet, error) + // GetTablets returns all tablets across all the specified clusters. + GetTablets(ctx context.Context, in *GetTabletsRequest, opts ...grpc.CallOption) (*GetTabletsResponse, error) +} + +type vTAdminClient struct { + cc *grpc.ClientConn +} + +func NewVTAdminClient(cc *grpc.ClientConn) VTAdminClient { + return &vTAdminClient{cc} +} + +func (c *vTAdminClient) GetGates(ctx context.Context, in *GetGatesRequest, opts ...grpc.CallOption) (*GetGatesResponse, error) { + out := new(GetGatesResponse) + err := c.cc.Invoke(ctx, "/vtadmin.VTAdmin/GetGates", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vTAdminClient) GetTablet(ctx context.Context, in *GetTabletRequest, opts ...grpc.CallOption) (*Tablet, error) { + out := new(Tablet) + err := c.cc.Invoke(ctx, "/vtadmin.VTAdmin/GetTablet", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vTAdminClient) GetTablets(ctx context.Context, in *GetTabletsRequest, opts ...grpc.CallOption) (*GetTabletsResponse, error) { + out := new(GetTabletsResponse) + err := c.cc.Invoke(ctx, "/vtadmin.VTAdmin/GetTablets", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// VTAdminServer is the server API for VTAdmin service. +type VTAdminServer interface { + // GetGates returns all gates across all the specified clusters. + GetGates(context.Context, *GetGatesRequest) (*GetGatesResponse, error) + // GetTablet looks up a tablet by hostname across all clusters and returns + // the result. + GetTablet(context.Context, *GetTabletRequest) (*Tablet, error) + // GetTablets returns all tablets across all the specified clusters. + GetTablets(context.Context, *GetTabletsRequest) (*GetTabletsResponse, error) +} + +// UnimplementedVTAdminServer can be embedded to have forward compatible implementations. +type UnimplementedVTAdminServer struct { +} + +func (*UnimplementedVTAdminServer) GetGates(ctx context.Context, req *GetGatesRequest) (*GetGatesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetGates not implemented") +} +func (*UnimplementedVTAdminServer) GetTablet(ctx context.Context, req *GetTabletRequest) (*Tablet, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetTablet not implemented") +} +func (*UnimplementedVTAdminServer) GetTablets(ctx context.Context, req *GetTabletsRequest) (*GetTabletsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetTablets not implemented") +} + +func RegisterVTAdminServer(s *grpc.Server, srv VTAdminServer) { + s.RegisterService(&_VTAdmin_serviceDesc, srv) +} + +func _VTAdmin_GetGates_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetGatesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VTAdminServer).GetGates(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/vtadmin.VTAdmin/GetGates", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VTAdminServer).GetGates(ctx, req.(*GetGatesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VTAdmin_GetTablet_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetTabletRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VTAdminServer).GetTablet(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/vtadmin.VTAdmin/GetTablet", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VTAdminServer).GetTablet(ctx, req.(*GetTabletRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VTAdmin_GetTablets_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetTabletsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VTAdminServer).GetTablets(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/vtadmin.VTAdmin/GetTablets", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VTAdminServer).GetTablets(ctx, req.(*GetTabletsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +var _VTAdmin_serviceDesc = grpc.ServiceDesc{ + ServiceName: "vtadmin.VTAdmin", + HandlerType: (*VTAdminServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetGates", + Handler: _VTAdmin_GetGates_Handler, + }, + { + MethodName: "GetTablet", + Handler: _VTAdmin_GetTablet_Handler, + }, + { + MethodName: "GetTablets", + Handler: _VTAdmin_GetTablets_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "vtadmin.proto", +} diff --git a/go/vt/vtadmin/README.md b/go/vt/vtadmin/README.md new file mode 100644 index 00000000000..69affca3dba --- /dev/null +++ b/go/vt/vtadmin/README.md @@ -0,0 +1,4 @@ +# VTAdmin + +VTAdmin is in alpha, you may use it if you wish, but should not treat it as stable. +It does not meet the standard Vitess backwards compatibility guarantees, and is subject to change at any time. diff --git a/go/vt/vtadmin/api.go b/go/vt/vtadmin/api.go new file mode 100644 index 00000000000..3e745d56fb1 --- /dev/null +++ b/go/vt/vtadmin/api.go @@ -0,0 +1,290 @@ +/* +Copyright 2020 The Vitess Authors. + +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 vtadmin + +import ( + "context" + "net/http" + "sync" + + "github.com/gorilla/handlers" + "github.com/gorilla/mux" + "vitess.io/vitess/go/trace" + "vitess.io/vitess/go/vt/concurrency" + "vitess.io/vitess/go/vt/vtadmin/cluster" + "vitess.io/vitess/go/vt/vtadmin/grpcserver" + vtadminhttp "vitess.io/vitess/go/vt/vtadmin/http" + vthandlers "vitess.io/vitess/go/vt/vtadmin/http/handlers" + "vitess.io/vitess/go/vt/vtadmin/sort" + "vitess.io/vitess/go/vt/vterrors" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtrpcpb "vitess.io/vitess/go/vt/proto/vtrpc" +) + +// API is the main entrypoint for the vtadmin server. It implements +// vtadminpb.VTAdminServer. +type API struct { + clusters []*cluster.Cluster + clusterMap map[string]*cluster.Cluster + serv *grpcserver.Server + router *mux.Router +} + +// NewAPI returns a new API, configured to service the given set of clusters, +// and configured with the given gRPC and HTTP server options. +func NewAPI(clusters []*cluster.Cluster, opts grpcserver.Options, httpOpts vtadminhttp.Options) *API { + clusterMap := make(map[string]*cluster.Cluster, len(clusters)) + for _, cluster := range clusters { + clusterMap[cluster.ID] = cluster + } + + sort.ClustersBy(func(c1, c2 *cluster.Cluster) bool { + return c1.ID < c2.ID + }).Sort(clusters) + + serv := grpcserver.New("vtadmin", opts) + serv.Router().HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok\n")) + }) + + router := serv.Router().PathPrefix("/api").Subrouter() + + api := &API{ + clusters: clusters, + clusterMap: clusterMap, + router: router, + serv: serv, + } + + vtadminpb.RegisterVTAdminServer(serv.GRPCServer(), api) + + httpAPI := vtadminhttp.NewAPI(api) + + router.HandleFunc("/gates", httpAPI.Adapt(vtadminhttp.GetGates)).Name("API.GetGates") + router.HandleFunc("/tablets", httpAPI.Adapt(vtadminhttp.GetTablets)).Name("API.GetTablets") + router.HandleFunc("/tablet/{tablet}", httpAPI.Adapt(vtadminhttp.GetTablet)).Name("API.GetTablet") + + // Middlewares are executed in order of addition. Our ordering (all + // middlewares being optional) is: + // 1. CORS. CORS is a special case and is applied globally, the rest are applied only to the subrouter. + // 2. Compression + // 3. Tracing + middlewares := []mux.MiddlewareFunc{} + + if len(httpOpts.CORSOrigins) > 0 { + serv.Router().Use(handlers.CORS( + handlers.AllowCredentials(), handlers.AllowedOrigins(httpOpts.CORSOrigins))) + } + + if !httpOpts.DisableCompression { + middlewares = append(middlewares, handlers.CompressHandler) + } + + if httpOpts.EnableTracing { + middlewares = append(middlewares, vthandlers.TraceHandler) + } + + router.Use(middlewares...) + + return api +} + +// ListenAndServe starts serving this API on the configured Addr (see +// grpcserver.Options) until shutdown or irrecoverable error occurs. +func (api *API) ListenAndServe() error { + return api.serv.ListenAndServe() +} + +// GetGates is part of the vtadminpb.VTAdminServer interface. +func (api *API) GetGates(ctx context.Context, req *vtadminpb.GetGatesRequest) (*vtadminpb.GetGatesResponse, error) { + span, ctx := trace.NewSpan(ctx, "API.GetGates") + defer span.Finish() + + clusters, _ := api.getClustersForRequest(req.ClusterIds) + + var ( + gates []*vtadminpb.VTGate + wg sync.WaitGroup + er concurrency.AllErrorRecorder + m sync.Mutex + ) + + for _, c := range clusters { + wg.Add(1) + + go func(c *cluster.Cluster) { + defer wg.Done() + + g, err := c.Discovery.DiscoverVTGates(ctx, []string{}) + if err != nil { + er.RecordError(err) + return + } + + m.Lock() + gates = append(gates, g...) + m.Unlock() + }(c) + } + + wg.Wait() + + if er.HasErrors() { + return nil, er.Error() + } + + return &vtadminpb.GetGatesResponse{ + Gates: gates, + }, nil +} + +// GetTablet is part of the vtadminpb.VTAdminServer interface. +func (api *API) GetTablet(ctx context.Context, req *vtadminpb.GetTabletRequest) (*vtadminpb.Tablet, error) { + span, ctx := trace.NewSpan(ctx, "API.GetTablet") + defer span.Finish() + + span.Annotate("tablet_hostname", req.Hostname) + + clusters, ids := api.getClustersForRequest(req.ClusterIds) + + var ( + tablets []*vtadminpb.Tablet + wg sync.WaitGroup + er concurrency.AllErrorRecorder + m sync.Mutex + ) + + for _, c := range clusters { + wg.Add(1) + + go func(c *cluster.Cluster) { + defer wg.Done() + + ts, err := api.getTablets(ctx, c) + if err != nil { + er.RecordError(err) + return + } + + var found []*vtadminpb.Tablet + + for _, t := range ts { + if t.Tablet.Hostname == req.Hostname { + found = append(found, t) + } + } + + m.Lock() + tablets = append(tablets, found...) + m.Unlock() + }(c) + } + + wg.Wait() + + if er.HasErrors() { + return nil, er.Error() + } + + switch len(tablets) { + case 0: + return nil, vterrors.Errorf(vtrpcpb.Code_NOT_FOUND, "%s: %s, searched clusters = %v", ErrNoTablet, req.Hostname, ids) + case 1: + return tablets[0], nil + } + + return nil, vterrors.Errorf(vtrpcpb.Code_NOT_FOUND, "%s: %s, searched clusters = %v", ErrAmbiguousTablet, req.Hostname, ids) +} + +// GetTablets is part of the vtadminpb.VTAdminServer interface. +func (api *API) GetTablets(ctx context.Context, req *vtadminpb.GetTabletsRequest) (*vtadminpb.GetTabletsResponse, error) { + span, ctx := trace.NewSpan(ctx, "API.GetTablets") + defer span.Finish() + + clusters, _ := api.getClustersForRequest(req.ClusterIds) + + var ( + tablets []*vtadminpb.Tablet + wg sync.WaitGroup + er concurrency.AllErrorRecorder + m sync.Mutex + ) + + for _, c := range clusters { + wg.Add(1) + + go func(c *cluster.Cluster) { + defer wg.Done() + + ts, err := api.getTablets(ctx, c) + if err != nil { + er.RecordError(err) + return + } + + m.Lock() + tablets = append(tablets, ts...) + m.Unlock() + }(c) + } + + wg.Wait() + + if er.HasErrors() { + return nil, er.Error() + } + + return &vtadminpb.GetTabletsResponse{ + Tablets: tablets, + }, nil +} + +func (api *API) getTablets(ctx context.Context, c *cluster.Cluster) ([]*vtadminpb.Tablet, error) { + if err := c.DB.Dial(ctx, ""); err != nil { + return nil, err + } + + rows, err := c.DB.ShowTablets(ctx) + if err != nil { + return nil, err + } + + return ParseTablets(rows, c) +} + +func (api *API) getClustersForRequest(ids []string) ([]*cluster.Cluster, []string) { + if len(ids) == 0 { + clusterIDs := make([]string, 0, len(api.clusters)) + + for k := range api.clusterMap { + clusterIDs = append(clusterIDs, k) + } + + return api.clusters, clusterIDs + } + + clusters := make([]*cluster.Cluster, 0, len(ids)) + + for _, id := range ids { + if c, ok := api.clusterMap[id]; ok { + clusters = append(clusters, c) + } + } + + return clusters, ids +} diff --git a/go/vt/vtadmin/api_test.go b/go/vt/vtadmin/api_test.go new file mode 100644 index 00000000000..a5b2f898eda --- /dev/null +++ b/go/vt/vtadmin/api_test.go @@ -0,0 +1,558 @@ +/* +Copyright 2020 The Vitess Authors. + +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 vtadmin + +import ( + "context" + "database/sql" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "vitess.io/vitess/go/vt/vitessdriver" + "vitess.io/vitess/go/vt/vtadmin/cluster" + "vitess.io/vitess/go/vt/vtadmin/cluster/discovery/fakediscovery" + "vitess.io/vitess/go/vt/vtadmin/grpcserver" + "vitess.io/vitess/go/vt/vtadmin/http" + "vitess.io/vitess/go/vt/vtadmin/vtsql" + "vitess.io/vitess/go/vt/vtadmin/vtsql/fakevtsql" + + topodatapb "vitess.io/vitess/go/vt/proto/topodata" + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +func TestGetGates(t *testing.T) { + fakedisco1 := fakediscovery.New() + cluster1 := &cluster.Cluster{ + ID: "c1", + Name: "cluster1", + Discovery: fakedisco1, + } + cluster1Gates := []*vtadminpb.VTGate{ + { + Hostname: "cluster1-gate1", + }, + { + Hostname: "cluster1-gate2", + }, + { + Hostname: "cluster1-gate3", + }, + } + + fakedisco1.AddTaggedGates(nil, cluster1Gates...) + + fakedisco2 := fakediscovery.New() + cluster2 := &cluster.Cluster{ + ID: "c2", + Name: "cluster2", + Discovery: fakedisco2, + } + cluster2Gates := []*vtadminpb.VTGate{ + { + Hostname: "cluster2-gate1", + }, + } + + fakedisco2.AddTaggedGates(nil, cluster2Gates...) + + api := NewAPI([]*cluster.Cluster{cluster1, cluster2}, grpcserver.Options{}, http.Options{}) + ctx := context.Background() + + resp, err := api.GetGates(ctx, &vtadminpb.GetGatesRequest{}) + assert.NoError(t, err) + assert.ElementsMatch(t, append(cluster1Gates, cluster2Gates...), resp.Gates) + + resp, err = api.GetGates(ctx, &vtadminpb.GetGatesRequest{ClusterIds: []string{cluster1.ID}}) + assert.NoError(t, err) + assert.ElementsMatch(t, cluster1Gates, resp.Gates) + + fakedisco1.SetGatesError(true) + + resp, err = api.GetGates(ctx, &vtadminpb.GetGatesRequest{}) + assert.Error(t, err) + assert.Nil(t, resp) +} + +func TestGetTablets(t *testing.T) { + tests := []struct { + name string + clusterTablets [][]*vtadminpb.Tablet + dbconfigs map[string]*dbcfg + req *vtadminpb.GetTabletsRequest + expected []*vtadminpb.Tablet + shouldErr bool + }{ + { + name: "single cluster", + clusterTablets: [][]*vtadminpb.Tablet{ + { + /* cluster 0 */ + { + State: vtadminpb.Tablet_SERVING, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{ + Uid: 100, + Cell: "zone1", + }, + Hostname: "ks1-00-00-zone1-a", + Keyspace: "ks1", + Shard: "-", + Type: topodatapb.TabletType_MASTER, + }, + }, + }, + }, + dbconfigs: map[string]*dbcfg{}, + req: &vtadminpb.GetTabletsRequest{}, + expected: []*vtadminpb.Tablet{ + { + Cluster: &vtadminpb.Cluster{ + Id: "c0", + Name: "cluster0", + }, + State: vtadminpb.Tablet_SERVING, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{ + Uid: 100, + Cell: "zone1", + }, + Hostname: "ks1-00-00-zone1-a", + Keyspace: "ks1", + Shard: "-", + Type: topodatapb.TabletType_MASTER, + }, + }, + }, + shouldErr: false, + }, + { + name: "one cluster errors", + clusterTablets: [][]*vtadminpb.Tablet{ + /* cluster 0 */ + { + { + State: vtadminpb.Tablet_SERVING, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{ + Uid: 100, + Cell: "zone1", + }, + Hostname: "ks1-00-00-zone1-a", + Keyspace: "ks1", + Shard: "-", + Type: topodatapb.TabletType_MASTER, + }, + }, + }, + /* cluster 1 */ + { + { + State: vtadminpb.Tablet_SERVING, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{ + Uid: 200, + Cell: "zone1", + }, + Hostname: "ks2-00-00-zone1-a", + Keyspace: "ks2", + Shard: "-", + Type: topodatapb.TabletType_MASTER, + }, + }, + }, + }, + dbconfigs: map[string]*dbcfg{ + "c1": {shouldErr: true}, + }, + req: &vtadminpb.GetTabletsRequest{}, + expected: nil, + shouldErr: true, + }, + { + name: "multi cluster, selecting one", + clusterTablets: [][]*vtadminpb.Tablet{ + /* cluster 0 */ + { + { + State: vtadminpb.Tablet_SERVING, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{ + Uid: 100, + Cell: "zone1", + }, + Hostname: "ks1-00-00-zone1-a", + Keyspace: "ks1", + Shard: "-", + Type: topodatapb.TabletType_MASTER, + }, + }, + }, + /* cluster 1 */ + { + { + State: vtadminpb.Tablet_SERVING, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{ + Uid: 200, + Cell: "zone1", + }, + Hostname: "ks2-00-00-zone1-a", + Keyspace: "ks2", + Shard: "-", + Type: topodatapb.TabletType_MASTER, + }, + }, + }, + }, + dbconfigs: map[string]*dbcfg{}, + req: &vtadminpb.GetTabletsRequest{ClusterIds: []string{"c0"}}, + expected: []*vtadminpb.Tablet{ + { + Cluster: &vtadminpb.Cluster{ + Id: "c0", + Name: "cluster0", + }, + State: vtadminpb.Tablet_SERVING, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{ + Uid: 100, + Cell: "zone1", + }, + Hostname: "ks1-00-00-zone1-a", + Keyspace: "ks1", + Shard: "-", + Type: topodatapb.TabletType_MASTER, + }, + }, + }, + shouldErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clusters := make([]*cluster.Cluster, len(tt.clusterTablets)) + + for i, tablets := range tt.clusterTablets { + cluster := buildCluster(i, tablets, tt.dbconfigs) + clusters[i] = cluster + } + + api := NewAPI(clusters, grpcserver.Options{}, http.Options{}) + resp, err := api.GetTablets(context.Background(), tt.req) + if tt.shouldErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.ElementsMatch(t, tt.expected, resp.Tablets) + }) + } +} + +// This test only validates the error handling on dialing database connections. +// Other cases are covered by one or both of TestGetTablets and TestGetTablet. +func Test_getTablets(t *testing.T) { + api := &API{} + disco := fakediscovery.New() + disco.AddTaggedGates(nil, &vtadminpb.VTGate{Hostname: "gate"}) + + db := vtsql.New("one", &vtsql.Config{ + Discovery: disco, + }) + db.DialFunc = func(cfg vitessdriver.Configuration) (*sql.DB, error) { + return nil, assert.AnError + } + + _, err := api.getTablets(context.Background(), &cluster.Cluster{ + DB: db, + }) + assert.Error(t, err) +} + +func TestGetTablet(t *testing.T) { + tests := []struct { + name string + clusterTablets [][]*vtadminpb.Tablet + dbconfigs map[string]*dbcfg + req *vtadminpb.GetTabletRequest + expected *vtadminpb.Tablet + shouldErr bool + }{ + { + name: "single cluster", + clusterTablets: [][]*vtadminpb.Tablet{ + { + /* cluster 0 */ + { + State: vtadminpb.Tablet_SERVING, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{ + Uid: 100, + Cell: "zone1", + }, + Hostname: "ks1-00-00-zone1-a", + Keyspace: "ks1", + Shard: "-", + Type: topodatapb.TabletType_MASTER, + }, + }, + }, + }, + dbconfigs: map[string]*dbcfg{}, + req: &vtadminpb.GetTabletRequest{ + Hostname: "ks1-00-00-zone1-a", + }, + expected: &vtadminpb.Tablet{ + Cluster: &vtadminpb.Cluster{ + Id: "c0", + Name: "cluster0", + }, + State: vtadminpb.Tablet_SERVING, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{ + Uid: 100, + Cell: "zone1", + }, + Hostname: "ks1-00-00-zone1-a", + Keyspace: "ks1", + Shard: "-", + Type: topodatapb.TabletType_MASTER, + }, + }, + shouldErr: false, + }, + { + name: "one cluster errors", + clusterTablets: [][]*vtadminpb.Tablet{ + /* cluster 0 */ + { + { + State: vtadminpb.Tablet_SERVING, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{ + Uid: 100, + Cell: "zone1", + }, + Hostname: "ks1-00-00-zone1-a", + Keyspace: "ks1", + Shard: "-", + Type: topodatapb.TabletType_MASTER, + }, + }, + }, + /* cluster 1 */ + { + { + State: vtadminpb.Tablet_SERVING, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{ + Uid: 200, + Cell: "zone1", + }, + Hostname: "ks2-00-00-zone1-a", + Keyspace: "ks2", + Shard: "-", + Type: topodatapb.TabletType_MASTER, + }, + }, + }, + }, + dbconfigs: map[string]*dbcfg{ + "c1": {shouldErr: true}, + }, + req: &vtadminpb.GetTabletRequest{ + Hostname: "doesn't matter", + }, + expected: nil, + shouldErr: true, + }, + { + name: "multi cluster, selecting one with tablet", + clusterTablets: [][]*vtadminpb.Tablet{ + /* cluster 0 */ + { + { + State: vtadminpb.Tablet_SERVING, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{ + Uid: 100, + Cell: "zone1", + }, + Hostname: "ks1-00-00-zone1-a", + Keyspace: "ks1", + Shard: "-", + Type: topodatapb.TabletType_MASTER, + }, + }, + }, + /* cluster 1 */ + { + { + State: vtadminpb.Tablet_SERVING, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{ + Uid: 200, + Cell: "zone1", + }, + Hostname: "ks2-00-00-zone1-a", + Keyspace: "ks2", + Shard: "-", + Type: topodatapb.TabletType_MASTER, + }, + }, + }, + }, + dbconfigs: map[string]*dbcfg{}, + req: &vtadminpb.GetTabletRequest{ + Hostname: "ks1-00-00-zone1-a", + ClusterIds: []string{"c0"}, + }, + expected: &vtadminpb.Tablet{ + Cluster: &vtadminpb.Cluster{ + Id: "c0", + Name: "cluster0", + }, + State: vtadminpb.Tablet_SERVING, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{ + Uid: 100, + Cell: "zone1", + }, + Hostname: "ks1-00-00-zone1-a", + Keyspace: "ks1", + Shard: "-", + Type: topodatapb.TabletType_MASTER, + }, + }, + shouldErr: false, + }, + { + name: "multi cluster, multiple results", + clusterTablets: [][]*vtadminpb.Tablet{ + /* cluster 0 */ + { + { + State: vtadminpb.Tablet_SERVING, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{ + Uid: 100, + Cell: "zone1", + }, + Hostname: "ks1-00-00-zone1-a", + Keyspace: "ks1", + Shard: "-", + Type: topodatapb.TabletType_MASTER, + }, + }, + }, + /* cluster 1 */ + { + { + State: vtadminpb.Tablet_SERVING, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{ + Uid: 200, + Cell: "zone1", + }, + Hostname: "ks1-00-00-zone1-a", + Keyspace: "ks1", + Shard: "-", + Type: topodatapb.TabletType_MASTER, + }, + }, + }, + }, + dbconfigs: map[string]*dbcfg{}, + req: &vtadminpb.GetTabletRequest{ + Hostname: "ks1-00-00-zone1-a", + }, + expected: nil, + shouldErr: true, + }, + { + name: "no results", + clusterTablets: [][]*vtadminpb.Tablet{ + /* cluster 0 */ + {}, + }, + dbconfigs: map[string]*dbcfg{}, + req: &vtadminpb.GetTabletRequest{ + Hostname: "ks1-00-00-zone1-a", + }, + expected: nil, + shouldErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clusters := make([]*cluster.Cluster, len(tt.clusterTablets)) + + for i, tablets := range tt.clusterTablets { + cluster := buildCluster(i, tablets, tt.dbconfigs) + clusters[i] = cluster + } + + api := NewAPI(clusters, grpcserver.Options{}, http.Options{}) + resp, err := api.GetTablet(context.Background(), tt.req) + if tt.shouldErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expected, resp) + }) + } +} + +type dbcfg struct { + shouldErr bool +} + +// shared helper for building a cluster that contains the given tablets. +// dbconfigs contains an optional config for controlling the behavior of the +// cluster's DB at the package sql level. +func buildCluster(i int, tablets []*vtadminpb.Tablet, dbconfigs map[string]*dbcfg) *cluster.Cluster { + disco := fakediscovery.New() + disco.AddTaggedGates(nil, &vtadminpb.VTGate{Hostname: fmt.Sprintf("cluster%d-gate", i)}) + + cluster := &cluster.Cluster{ + ID: fmt.Sprintf("c%d", i), + Name: fmt.Sprintf("cluster%d", i), + Discovery: disco, + } + + dbconfig, ok := dbconfigs[cluster.ID] + if !ok { + dbconfig = &dbcfg{shouldErr: false} + } + + db := vtsql.New(cluster.ID, &vtsql.Config{ + Discovery: disco, + }) + db.DialFunc = func(cfg vitessdriver.Configuration) (*sql.DB, error) { + return sql.OpenDB(&fakevtsql.Connector{Tablets: tablets, ShouldErr: dbconfig.shouldErr}), nil + } + + cluster.DB = db + + return cluster +} diff --git a/go/vt/vtadmin/cluster/cluster.go b/go/vt/vtadmin/cluster/cluster.go new file mode 100644 index 00000000000..da6375fc8eb --- /dev/null +++ b/go/vt/vtadmin/cluster/cluster.go @@ -0,0 +1,81 @@ +/* +Copyright 2020 The Vitess Authors. + +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 cluster + +import ( + "fmt" + + "vitess.io/vitess/go/vt/vtadmin/cluster/discovery" + "vitess.io/vitess/go/vt/vtadmin/vtsql" +) + +// Cluster is the self-contained unit of services required for vtadmin to talk +// to a vitess cluster. This consists of a discovery service, a database +// connection, and a vtctl client. +type Cluster struct { + ID string + Name string + Discovery discovery.Discovery + + // (TODO|@amason): after merging #7128, this still requires some additional + // work, so deferring this for now! + // vtctl vtctldclient.VtctldClient + DB vtsql.DB + + // These fields are kept to power debug endpoints. + // (TODO|@amason): Figure out if these are needed or if there's a way to + // push down to the credentials / vtsql. + // vtgateCredentialsPath string +} + +// New creates a new Cluster from a Config. +func New(cfg Config) (*Cluster, error) { + cluster := &Cluster{ + ID: cfg.ID, + Name: cfg.Name, + } + + discoargs := buildPFlagSlice(cfg.DiscoveryFlagsByImpl[cfg.DiscoveryImpl]) + + disco, err := discovery.New(cfg.DiscoveryImpl, cluster.Name, discoargs) + if err != nil { + return nil, fmt.Errorf("error while creating discovery impl (%s): %w", cfg.DiscoveryImpl, err) + } + + cluster.Discovery = disco + + vtsqlargs := buildPFlagSlice(cfg.VtSQLFlags) + + vtsqlCfg, err := vtsql.Parse(cluster.ID, cluster.Name, disco, vtsqlargs) + if err != nil { + return nil, fmt.Errorf("error while creating vtsql connection: %w", err) + } + + cluster.DB = vtsql.New(cluster.Name, vtsqlCfg) + + return cluster, nil +} + +func buildPFlagSlice(flags map[string]string) []string { + args := make([]string, 0, len(flags)) + for k, v := range flags { + // The k=v syntax is needed to account for negating boolean flags. + args = append(args, "--"+k+"="+v) + } + + return args +} diff --git a/go/vt/vtadmin/cluster/config.go b/go/vt/vtadmin/cluster/config.go new file mode 100644 index 00000000000..1b641f60458 --- /dev/null +++ b/go/vt/vtadmin/cluster/config.go @@ -0,0 +1,114 @@ +/* +Copyright 2020 The Vitess Authors. + +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 cluster + +import "fmt" + +// Config represents the options to configure a vtadmin cluster. +type Config struct { + ID string + Name string + DiscoveryImpl string + DiscoveryFlagsByImpl FlagsByImpl + VtSQLFlags map[string]string +} + +// Cluster returns a new cluster instance from the given config. +func (cfg Config) Cluster() (*Cluster, error) { + return New(cfg) +} + +// String is part of the flag.Value interface. +func (cfg *Config) String() string { return fmt.Sprintf("%T:%+v", cfg, *cfg) } + +// Type is part of the pflag.Value interface. +func (cfg *Config) Type() string { return "cluster.Config" } + +// Set is part of the flag.Value interface. Each flag is parsed according to the +// following DSN: +// +// id= // ID or shortname of the cluster. +// name= // Name of the cluster. +// discovery= // Name of the discovery implementation +// discovery-.*= // Per-discovery-implementation flags. These are passed to +// // a given discovery implementation's constructor. +// vtsql-.*= // VtSQL-specific flags. Further parsing of these is delegated +// // to the vtsql package. +func (cfg *Config) Set(value string) error { + if cfg.DiscoveryFlagsByImpl == nil { + cfg.DiscoveryFlagsByImpl = map[string]map[string]string{} + } + + return parseFlag(cfg, value) +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (cfg *Config) UnmarshalYAML(unmarshal func(interface{}) error) error { + attributes := map[string]string{} + + if err := unmarshal(attributes); err != nil { + return err + } + + for k, v := range attributes { + if err := parseOne(cfg, k, v); err != nil { + return err + } + } + + return nil +} + +// Merge returns the result of merging the calling config into the passed +// config. Neither the caller or the argument are modified in any way. +func (cfg Config) Merge(override Config) Config { + merged := Config{ + ID: cfg.ID, + Name: cfg.Name, + DiscoveryImpl: cfg.DiscoveryImpl, + DiscoveryFlagsByImpl: map[string]map[string]string{}, + VtSQLFlags: map[string]string{}, + } + + if override.ID != "" { + merged.ID = override.ID + } + + if override.Name != "" { + merged.Name = override.Name + } + + if override.DiscoveryImpl != "" { + merged.DiscoveryImpl = override.DiscoveryImpl + } + + // first, the default flags + merged.DiscoveryFlagsByImpl.Merge(cfg.DiscoveryFlagsByImpl) + // then, apply any overrides + merged.DiscoveryFlagsByImpl.Merge(override.DiscoveryFlagsByImpl) + + mergeStringMap(merged.VtSQLFlags, cfg.VtSQLFlags) + mergeStringMap(merged.VtSQLFlags, override.VtSQLFlags) + + return merged +} + +func mergeStringMap(base map[string]string, override map[string]string) { + for k, v := range override { + base[k] = v + } +} diff --git a/go/vt/vtadmin/cluster/config_test.go b/go/vt/vtadmin/cluster/config_test.go new file mode 100644 index 00000000000..22839b38323 --- /dev/null +++ b/go/vt/vtadmin/cluster/config_test.go @@ -0,0 +1,190 @@ +/* +Copyright 2020 The Vitess Authors. + +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 cluster + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" +) + +func TestMergeConfig(t *testing.T) { + tests := []struct { + name string + base Config + override Config + expected Config + }{ + { + name: "no flags", + base: Config{ + ID: "c1", + Name: "cluster1", + }, + override: Config{ + DiscoveryImpl: "consul", + }, + expected: Config{ + ID: "c1", + Name: "cluster1", + DiscoveryImpl: "consul", + DiscoveryFlagsByImpl: FlagsByImpl{}, + VtSQLFlags: map[string]string{}, + }, + }, + { + name: "merging discovery flags", + base: Config{ + ID: "c1", + Name: "cluster1", + DiscoveryFlagsByImpl: map[string]map[string]string{ + "consul": { + "key1": "val1", + }, + "zk": { + "foo": "bar", + }, + }, + VtSQLFlags: map[string]string{}, + }, + override: Config{ + DiscoveryFlagsByImpl: map[string]map[string]string{ + "zk": { + "foo": "baz", + }, + }, + }, + expected: Config{ + ID: "c1", + Name: "cluster1", + DiscoveryFlagsByImpl: map[string]map[string]string{ + "consul": { + "key1": "val1", + }, + "zk": { + "foo": "baz", + }, + }, + VtSQLFlags: map[string]string{}, + }, + }, + { + name: "merging vtsql flags", + base: Config{ + ID: "c1", + Name: "cluster1", + VtSQLFlags: map[string]string{ + "one": "one", + "two": "2", + }, + }, + override: Config{ + ID: "c1", + Name: "cluster1", + VtSQLFlags: map[string]string{ + "two": "two", + "three": "three", + }, + }, + expected: Config{ + ID: "c1", + Name: "cluster1", + DiscoveryFlagsByImpl: FlagsByImpl{}, + VtSQLFlags: map[string]string{ + "one": "one", + "two": "two", + "three": "three", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := tt.base.Merge(tt.override) + assert.Equal(t, tt.expected, actual) + }) + } +} + +func TestConfigUnmarshalYAML(t *testing.T) { + tests := []struct { + name string + yaml string + config Config + err error + }{ + { + name: "simple", + yaml: `name: cluster1 +id: c1`, + config: Config{ + ID: "c1", + Name: "cluster1", + DiscoveryFlagsByImpl: map[string]map[string]string{}, + }, + err: nil, + }, + { + name: "discovery flags", + yaml: `name: cluster1 +id: c1 +discovery: consul +discovery-consul-vtgate-datacenter-tmpl: "dev-{{ .Cluster }}" +discovery-zk-whatever: 5 +`, + config: Config{ + ID: "c1", + Name: "cluster1", + DiscoveryImpl: "consul", + DiscoveryFlagsByImpl: map[string]map[string]string{ + "consul": { + "vtgate-datacenter-tmpl": "dev-{{ .Cluster }}", + }, + "zk": { + "whatever": "5", + }, + }, + }, + }, + { + name: "errors", + yaml: `name: "cluster1`, + config: Config{}, + err: assert.AnError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := Config{ + DiscoveryFlagsByImpl: map[string]map[string]string{}, + } + + err := yaml.Unmarshal([]byte(tt.yaml), &cfg) + if tt.err != nil { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.config, cfg) + }) + } +} diff --git a/go/vt/vtadmin/cluster/discovery/discovery.go b/go/vt/vtadmin/cluster/discovery/discovery.go new file mode 100644 index 00000000000..2aaf53e194e --- /dev/null +++ b/go/vt/vtadmin/cluster/discovery/discovery.go @@ -0,0 +1,94 @@ +/* +Copyright 2020 The Vitess Authors. + +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 discovery + +import ( + "context" + "errors" + "fmt" + + "github.com/spf13/pflag" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +var ( + // ErrImplementationNotRegistered is returned from discovery.New if it is + // called with an unknown implementation. + ErrImplementationNotRegistered = errors.New("no discovery factory registered for implementation") + // ErrNoVTGates should be returned from DiscoverVTGate* methods when they + // are unable to find any vtgates for the given filter/query/tags. + ErrNoVTGates = errors.New("no vtgates found") +) + +// Discovery defines the interface that service discovery plugins must +// implement. See ConsulDiscovery for an example implementation. +type Discovery interface { + // DiscoverVTGate returns a vtgate found in the discovery service. + // Tags can optionally be used to filter the set of potential gates further. + // Which gate in a set of found gates is returned is not specified by the + // interface, and can be implementation-specific. + DiscoverVTGate(ctx context.Context, tags []string) (*vtadminpb.VTGate, error) + // DiscoverVTGateAddr returns the address of a of vtgate found in the + // discovery service. Tags can optionally be used to filter the set of + // potential gates further. Which gate in a set of found gates is used to + // return an address is not specified by the interface, and can be + // implementation-specific. + DiscoverVTGateAddr(ctx context.Context, tags []string) (string, error) + // DiscoverVTGates returns a list of vtgates found in the discovery service. + // Tags can optionally be used to filter gates. Order of the gates is not + // specified by the interface, and can be implementation-specific. + DiscoverVTGates(ctx context.Context, tags []string) ([]*vtadminpb.VTGate, error) +} + +// Factory represents a function that can create a Discovery implementation. +// This package will provide several implementations and register them for use. +// The flags FlagSet is provided for convenience, but also to hint to plugin +// developers that they should expect the args to be in a format compatible with +// pflag. +type Factory func(cluster string, flags *pflag.FlagSet, args []string) (Discovery, error) + +// nolint:gochecknoglobals +var registry = map[string]Factory{} + +// Register registers a factory for the given implementation name. Attempting +// to register multiple factories for the same implementation name causes a +// panic. +func Register(name string, factory Factory) { + _, ok := registry[name] + if ok { + panic("[discovery] factory already registered for " + name) + } + + registry[name] = factory +} + +// New returns a Discovery implementation using the registered factory for the +// implementation. Usage of the args slice is dependent on the implementation's +// factory. +func New(impl string, cluster string, args []string) (Discovery, error) { + factory, ok := registry[impl] + if !ok { + return nil, fmt.Errorf("%w %s", ErrImplementationNotRegistered, impl) + } + + return factory(cluster, pflag.NewFlagSet("discovery:"+impl, pflag.ContinueOnError), args) +} + +func init() { // nolint:gochecknoinits + Register("consul", NewConsul) +} diff --git a/go/vt/vtadmin/cluster/discovery/discovery_consul.go b/go/vt/vtadmin/cluster/discovery/discovery_consul.go new file mode 100644 index 00000000000..dbeea6b3302 --- /dev/null +++ b/go/vt/vtadmin/cluster/discovery/discovery_consul.go @@ -0,0 +1,252 @@ +/* +Copyright 2020 The Vitess Authors. + +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 discovery + +import ( + "bytes" + "context" + "math/rand" + "strings" + "text/template" + "time" + + consul "github.com/hashicorp/consul/api" + "github.com/spf13/pflag" + "vitess.io/vitess/go/trace" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +// ConsulDiscovery implements the Discovery interface for consul. +type ConsulDiscovery struct { + cluster string + client ConsulClient + queryOptions *consul.QueryOptions + + /* misc options */ + passingOnly bool + + /* vtgate options */ + vtgateDatacenter string + vtgateService string + vtgatePoolTag string + vtgateCellTag string + vtgateKeyspacesToWatchTag string + vtgateAddrTmpl *template.Template +} + +// NewConsul returns a ConsulDiscovery for the given cluster. Args are a slice +// of command-line flags (e.g. "-key=value") that are parsed by a consul- +// specific flag set. +func NewConsul(cluster string, flags *pflag.FlagSet, args []string) (Discovery, error) { // nolint:funlen + c, err := consul.NewClient(consul.DefaultConfig()) + if err != nil { + return nil, err + } + + qopts := &consul.QueryOptions{ + AllowStale: false, + RequireConsistent: true, + WaitIndex: uint64(0), + UseCache: true, + } + + disco := &ConsulDiscovery{ + cluster: cluster, + client: &consulClient{c}, + queryOptions: qopts, + } + + flags.DurationVar(&disco.queryOptions.MaxAge, "max-age", time.Second*30, + "how old a cached value can be before consul queries stop using it") + flags.StringVar(&disco.queryOptions.Token, "token", "", "consul ACL token to use for requests") + flags.BoolVar(&disco.passingOnly, "passing-only", true, "whether to include only nodes passing healthchecks") + + flags.StringVar(&disco.vtgateService, "vtgate-service-name", "vtgate", "consul service name vtgates register as") + flags.StringVar(&disco.vtgatePoolTag, "vtgate-pool-tag", "pool", "consul service tag to group vtgates by pool") + flags.StringVar(&disco.vtgateCellTag, "vtgate-cell-tag", "cell", "consul service tag to group vtgates by cell") + flags.StringVar(&disco.vtgateKeyspacesToWatchTag, "vtgate-keyspaces-to-watch-tag", "keyspaces", + "consul service tag identifying -keyspaces_to_watch for vtgates") + + vtgateAddrTmplStr := flags.String("vtgate-addr-tmpl", "{{ .Hostname }}", + "Go template string to produce a dialable address from a *vtadminpb.VTGate") + vtgateDatacenterTmplStr := flags.String("vtgate-datacenter-tmpl", "", + "Go template string to generate the datacenter for vtgate consul queries. "+ + "The cluster name is provided to the template via {{ .Cluster }}. Used once during initialization.") + + if err := flags.Parse(args); err != nil { + return nil, err + } + + if *vtgateDatacenterTmplStr != "" { + tmpl, err := template.New("consul-vtgate-datacenter-" + cluster).Parse(*vtgateDatacenterTmplStr) + if err != nil { + return nil, err + } + + buf := bytes.NewBuffer(nil) + err = tmpl.Execute(buf, &struct { + Cluster string + }{ + Cluster: cluster, + }) + + if err != nil { + return nil, err + } + + disco.vtgateDatacenter = buf.String() + } + + disco.vtgateAddrTmpl, err = template.New("consul-vtgate-address-template").Parse(*vtgateAddrTmplStr) + if err != nil { + return nil, err + } + + return disco, nil +} + +// DiscoverVTGate is part of the Discovery interface. +func (c *ConsulDiscovery) DiscoverVTGate(ctx context.Context, tags []string) (*vtadminpb.VTGate, error) { + span, ctx := trace.NewSpan(ctx, "ConsulDiscovery.DiscoverVTGate") + defer span.Finish() + + return c.discoverVTGate(ctx, tags) +} + +func (c *ConsulDiscovery) discoverVTGate(ctx context.Context, tags []string) (*vtadminpb.VTGate, error) { + vtgates, err := c.discoverVTGates(ctx, tags) + if err != nil { + return nil, err + } + + if len(vtgates) == 0 { + return nil, ErrNoVTGates + } + + return vtgates[rand.Intn(len(vtgates))], nil +} + +// DiscoverVTGateAddr is part of the Discovery interface. +func (c *ConsulDiscovery) DiscoverVTGateAddr(ctx context.Context, tags []string) (string, error) { + span, ctx := trace.NewSpan(ctx, "ConsulDiscovery.DiscoverVTGateAddr") + defer span.Finish() + + vtgate, err := c.discoverVTGate(ctx, tags) + if err != nil { + return "", err + } + + buf := bytes.NewBuffer(nil) + if err := c.vtgateAddrTmpl.Execute(buf, vtgate); err != nil { + return "", err + } + + return buf.String(), nil +} + +// DiscoverVTGates is part of the Discovery interface. +func (c *ConsulDiscovery) DiscoverVTGates(ctx context.Context, tags []string) ([]*vtadminpb.VTGate, error) { + span, ctx := trace.NewSpan(ctx, "ConsulDiscovery.DiscoverVTGates") + defer span.Finish() + + return c.discoverVTGates(ctx, tags) +} + +func (c *ConsulDiscovery) discoverVTGates(_ context.Context, tags []string) ([]*vtadminpb.VTGate, error) { + opts := c.getQueryOptions() + opts.Datacenter = c.vtgateDatacenter + + entries, _, err := c.client.Health().ServiceMultipleTags(c.vtgateService, tags, c.passingOnly, &opts) + if err != nil { + return nil, err + } + + vtgates := make([]*vtadminpb.VTGate, len(entries)) + + for i, entry := range entries { + vtgate := &vtadminpb.VTGate{ + Hostname: entry.Node.Node, + Cluster: c.cluster, + } + + var cell, pool string + for _, tag := range entry.Service.Tags { + if pool != "" && cell != "" { + break + } + + parts := strings.Split(tag, ":") + if len(parts) != 2 { + continue + } + + name, value := parts[0], parts[1] + switch name { + case c.vtgateCellTag: + cell = value + case c.vtgatePoolTag: + pool = value + } + } + + vtgate.Cell = cell + vtgate.Pool = pool + + if keyspaces, ok := entry.Service.Meta[c.vtgateKeyspacesToWatchTag]; ok { + vtgate.Keyspaces = strings.Split(keyspaces, ",") + } + + vtgates[i] = vtgate + } + + return vtgates, nil +} + +// getQueryOptions returns a shallow copy so we can swap in the vtgateDatacenter. +// If we were to set it directly, we'd need a mutex to guard against concurrent +// vtgate and (soon) vtctld queries. +func (c *ConsulDiscovery) getQueryOptions() consul.QueryOptions { + if c.queryOptions == nil { + return consul.QueryOptions{} + } + + opts := *c.queryOptions + + return opts +} + +// ConsulClient defines an interface for the subset of the consul API used by +// discovery, so we can swap in an implementation for testing. +type ConsulClient interface { + Health() ConsulHealth +} + +// ConsulHealth defines an interface for the subset of the (*consul.Health) struct +// used by discovery, so we can swap in an implementation for testing. +type ConsulHealth interface { + ServiceMultipleTags(service string, tags []string, passingOnly bool, q *consul.QueryOptions) ([]*consul.ServiceEntry, *consul.QueryMeta, error) // nolint:lll +} + +// consulClient is our shim wrapper around the upstream consul client. +type consulClient struct { + *consul.Client +} + +func (c *consulClient) Health() ConsulHealth { + return c.Client.Health() +} diff --git a/go/vt/vtadmin/cluster/discovery/discovery_consul_test.go b/go/vt/vtadmin/cluster/discovery/discovery_consul_test.go new file mode 100644 index 00000000000..88e73a07864 --- /dev/null +++ b/go/vt/vtadmin/cluster/discovery/discovery_consul_test.go @@ -0,0 +1,391 @@ +/* +Copyright 2020 The Vitess Authors. + +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 discovery + +import ( + "context" + "sort" + "testing" + "text/template" + + consul "github.com/hashicorp/consul/api" + "github.com/stretchr/testify/assert" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +type fakeConsulClient struct { + health *fakeConsulHealth +} + +func (c *fakeConsulClient) Health() ConsulHealth { return c.health } + +type fakeConsulHealth struct { + entries map[string][]*consul.ServiceEntry +} + +func (health *fakeConsulHealth) ServiceMultipleTags(service string, tags []string, passingOnly bool, q *consul.QueryOptions) ([]*consul.ServiceEntry, *consul.QueryMeta, error) { // nolint:lll + if health.entries == nil { + return nil, nil, assert.AnError + } + + sort.Strings(tags) + + serviceEntries, ok := health.entries[service] + if !ok { + return []*consul.ServiceEntry{}, nil, nil + } + + filterByTags := func(etags []string) bool { + sort.Strings(etags) + + for _, tag := range tags { + i := sort.SearchStrings(etags, tag) + if i >= len(etags) || etags[i] != tag { + return false + } + } + + return true + } + + filteredEntries := make([]*consul.ServiceEntry, 0, len(serviceEntries)) + + for _, entry := range serviceEntries { + if filterByTags(append([]string{}, entry.Service.Tags...)) { // we take a copy here to not mutate the original slice + filteredEntries = append(filteredEntries, entry) + } + } + + return filteredEntries, nil, nil +} + +func consulServiceEntry(name string, tags []string, meta map[string]string) *consul.ServiceEntry { + return &consul.ServiceEntry{ + Node: &consul.Node{ + Node: name, + }, + Service: &consul.AgentService{ + Meta: meta, + Tags: tags, + }, + } +} + +func TestConsulDiscoverVTGates(t *testing.T) { + tests := []struct { + name string + disco *ConsulDiscovery + tags []string + entries map[string][]*consul.ServiceEntry + expected []*vtadminpb.VTGate + shouldErr bool + }{ + { + name: "all gates", + disco: &ConsulDiscovery{ + cluster: "cluster", + vtgateService: "vtgate", + vtgateCellTag: "cell", + vtgatePoolTag: "pool", + }, + tags: []string{}, + entries: map[string][]*consul.ServiceEntry{ + "vtgate": { + consulServiceEntry("vtgate1", []string{"pool:pool1", "cell:zone1", "extra:tag"}, nil), + consulServiceEntry("vtgate2", []string{"pool:pool1", "cell:zone2"}, nil), + consulServiceEntry("vtgate3", []string{"pool:pool1", "cell:zone3"}, nil), + }, + }, + expected: []*vtadminpb.VTGate{ + { + Cluster: "cluster", + Hostname: "vtgate1", + Cell: "zone1", + Pool: "pool1", + }, + { + Cluster: "cluster", + Hostname: "vtgate2", + Cell: "zone2", + Pool: "pool1", + }, + { + Cluster: "cluster", + Hostname: "vtgate3", + Cell: "zone3", + Pool: "pool1", + }, + }, + shouldErr: false, + }, + { + name: "one cell", + disco: &ConsulDiscovery{ + cluster: "cluster", + vtgateService: "vtgate", + vtgateCellTag: "cell", + vtgatePoolTag: "pool", + }, + tags: []string{"cell:zone1"}, + entries: map[string][]*consul.ServiceEntry{ + "vtgate": { + consulServiceEntry("vtgate1", []string{"pool:pool1", "cell:zone1", "extra:tag"}, nil), + consulServiceEntry("vtgate2", []string{"pool:pool1", "cell:zone2"}, nil), + consulServiceEntry("vtgate3", []string{"pool:pool1", "cell:zone3"}, nil), + }, + }, + expected: []*vtadminpb.VTGate{ + { + Cluster: "cluster", + Hostname: "vtgate1", + Cell: "zone1", + Pool: "pool1", + }, + }, + shouldErr: false, + }, + { + name: "keyspaces to watch", + disco: &ConsulDiscovery{ + cluster: "cluster", + vtgateService: "vtgate", + vtgateCellTag: "cell", + vtgatePoolTag: "pool", + vtgateKeyspacesToWatchTag: "keyspaces", + }, + tags: []string{}, + entries: map[string][]*consul.ServiceEntry{ + "vtgate": { + consulServiceEntry("vtgate1", []string{"pool:pool1", "cell:zone1"}, map[string]string{"keyspaces": "ks1,ks2"}), + }, + }, + expected: []*vtadminpb.VTGate{ + { + Cluster: "cluster", + Hostname: "vtgate1", + Cell: "zone1", + Pool: "pool1", + Keyspaces: []string{"ks1", "ks2"}, + }, + }, + shouldErr: false, + }, + { + name: "error", + disco: &ConsulDiscovery{ + cluster: "cluster", + vtgateService: "vtgate", + vtgateCellTag: "cell", + vtgatePoolTag: "pool", + vtgateKeyspacesToWatchTag: "keyspaces", + }, + tags: []string{}, + entries: nil, + expected: []*vtadminpb.VTGate{}, + shouldErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.disco.client = &fakeConsulClient{ + health: &fakeConsulHealth{ + entries: tt.entries, + }, + } + + gates, err := tt.disco.DiscoverVTGates(context.Background(), tt.tags) + if tt.shouldErr { + assert.Error(t, err, assert.AnError) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expected, gates) + }) + } +} + +func TestConsulDiscoverVTGate(t *testing.T) { + tests := []struct { + name string + disco *ConsulDiscovery + tags []string + entries map[string][]*consul.ServiceEntry + expected *vtadminpb.VTGate + shouldErr bool + }{ + { + name: "success", + disco: &ConsulDiscovery{ + cluster: "cluster", + vtgateService: "vtgate", + vtgateCellTag: "cell", + vtgatePoolTag: "pool", + }, + tags: []string{"cell:zone1"}, + entries: map[string][]*consul.ServiceEntry{ + "vtgate": { + consulServiceEntry("vtgate1", []string{"pool:pool1", "cell:zone1"}, nil), + consulServiceEntry("vtgate2", []string{"pool:pool1", "cell:zone2"}, nil), + consulServiceEntry("vtgate3", []string{"pool:pool1", "cell:zone3"}, nil), + }, + }, + expected: &vtadminpb.VTGate{ + Cluster: "cluster", + Hostname: "vtgate1", + Cell: "zone1", + Pool: "pool1", + }, + shouldErr: false, + }, + { + name: "no gates", + disco: &ConsulDiscovery{ + cluster: "cluster", + vtgateService: "vtgate", + vtgateCellTag: "cell", + vtgatePoolTag: "pool", + }, + tags: []string{"cell:zone1"}, + entries: map[string][]*consul.ServiceEntry{ + "vtgate": {}, + }, + expected: &vtadminpb.VTGate{ + Cluster: "cluster", + Hostname: "vtgate1", + Cell: "zone1", + Pool: "pool1", + }, + shouldErr: true, + }, + { + name: "error", + disco: &ConsulDiscovery{ + cluster: "cluster", + vtgateService: "vtgate", + vtgateCellTag: "cell", + vtgatePoolTag: "pool", + }, + tags: []string{"cell:zone1"}, + entries: nil, + expected: nil, + shouldErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.disco.client = &fakeConsulClient{ + health: &fakeConsulHealth{ + entries: tt.entries, + }, + } + + gate, err := tt.disco.DiscoverVTGate(context.Background(), tt.tags) + if tt.shouldErr { + assert.Error(t, err, assert.AnError) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expected, gate) + }) + } +} + +func TestConsulDiscoverVTGateAddr(t *testing.T) { + tests := []struct { + name string + disco *ConsulDiscovery + tags []string + entries map[string][]*consul.ServiceEntry + expected string + shouldErr bool + }{ + { + name: "default template", + disco: &ConsulDiscovery{ + cluster: "cluster", + vtgateService: "vtgate", + vtgateCellTag: "cell", + vtgatePoolTag: "pool", + vtgateAddrTmpl: template.Must(template.New("").Parse("{{ .Hostname }}")), + }, + tags: []string{}, + entries: map[string][]*consul.ServiceEntry{ + "vtgate": { + consulServiceEntry("vtgate1", []string{"pool:pool1", "cell:zone1"}, nil), + }, + }, + expected: "vtgate1", + shouldErr: false, + }, + { + name: "custom template", + disco: &ConsulDiscovery{ + cluster: "cluster", + vtgateService: "vtgate", + vtgateCellTag: "cell", + vtgatePoolTag: "pool", + vtgateAddrTmpl: template.Must(template.New("").Parse("{{ .Cluster }}-{{ .Pool }}-{{ .Cell }}-{{ .Hostname }}.example.com:15000")), // nolint:lll + }, + tags: []string{}, + entries: map[string][]*consul.ServiceEntry{ + "vtgate": { + consulServiceEntry("vtgate1", []string{"pool:pool1", "cell:zone1"}, nil), + }, + }, + expected: "cluster-pool1-zone1-vtgate1.example.com:15000", + shouldErr: false, + }, + { + name: "error", + disco: &ConsulDiscovery{ + cluster: "cluster", + vtgateService: "vtgate", + vtgateCellTag: "cell", + vtgatePoolTag: "pool", + vtgateAddrTmpl: template.Must(template.New("").Parse("{{ .Hostname }}")), + }, + tags: []string{}, + entries: nil, + expected: "", + shouldErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.disco.client = &fakeConsulClient{ + health: &fakeConsulHealth{ + entries: tt.entries, + }, + } + + addr, err := tt.disco.DiscoverVTGateAddr(context.Background(), tt.tags) + if tt.shouldErr { + assert.Error(t, err, assert.AnError) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expected, addr) + }) + } +} diff --git a/go/vt/vtadmin/cluster/discovery/discovery_test.go b/go/vt/vtadmin/cluster/discovery/discovery_test.go new file mode 100644 index 00000000000..6c2b4514a2a --- /dev/null +++ b/go/vt/vtadmin/cluster/discovery/discovery_test.go @@ -0,0 +1,73 @@ +/* +Copyright 2020 The Vitess Authors. + +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 discovery + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNew(t *testing.T) { + tests := []struct { + name string + impl string + err error + typ Discovery + }{ + { + name: "success", + impl: "consul", + err: nil, + typ: &ConsulDiscovery{}, + }, + { + name: "unregistered", + impl: "unregistered", + err: ErrImplementationNotRegistered, + typ: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + disco, err := New(tt.impl, "testcluster", []string{}) + if tt.err != nil { + assert.Error(t, err, tt.err.Error()) + return + } + + assert.NoError(t, err) + assert.IsType(t, tt.typ, disco) + }) + } +} + +func TestRegister(t *testing.T) { + Register("testfactory", nil) + + defer func() { + err := recover() + assert.NotNil(t, err) + assert.IsType(t, "", err) + assert.Contains(t, err.(string), "factory already registered") + }() + + // this one panics + Register("testfactory", nil) + assert.Equal(t, 1, 2, "double register should have panicked") +} diff --git a/go/vt/vtadmin/cluster/discovery/fakediscovery/discovery.go b/go/vt/vtadmin/cluster/discovery/fakediscovery/discovery.go new file mode 100644 index 00000000000..08c42709b76 --- /dev/null +++ b/go/vt/vtadmin/cluster/discovery/fakediscovery/discovery.go @@ -0,0 +1,138 @@ +/* +Copyright 2020 The Vitess Authors. + +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 fakediscovery provides a fake, in-memory discovery implementation. +package fakediscovery + +import ( + "context" + "math/rand" + + "github.com/stretchr/testify/assert" + "vitess.io/vitess/go/vt/vtadmin/cluster/discovery" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +type gates struct { + byTag map[string][]*vtadminpb.VTGate + byName map[string]*vtadminpb.VTGate + shouldErr bool +} + +// Fake is a fake discovery implementation for use in testing. +type Fake struct { + gates *gates +} + +// New returns a new fake. +func New() *Fake { + return &Fake{ + gates: &gates{ + byTag: map[string][]*vtadminpb.VTGate{}, + byName: map[string]*vtadminpb.VTGate{}, + }, + } +} + +// AddTaggedGates adds the given gates to the discovery fake, associating each +// gate with each tag. To tag different gates with multiple tags, call multiple +// times with the same gates but different tag slices. Gates an uniquely +// identified by hostname. +func (d *Fake) AddTaggedGates(tags []string, gates ...*vtadminpb.VTGate) { + for _, tag := range tags { + d.gates.byTag[tag] = append(d.gates.byTag[tag], gates...) + } + + for _, g := range gates { + d.gates.byName[g.Hostname] = g + } +} + +// SetGatesError instructs whether the fake should return an error on gate +// discovery functions. +func (d *Fake) SetGatesError(shouldErr bool) { + d.gates.shouldErr = shouldErr +} + +var _ discovery.Discovery = (*Fake)(nil) + +// DiscoverVTGates is part of the discovery.Discovery interface. +func (d *Fake) DiscoverVTGates(ctx context.Context, tags []string) ([]*vtadminpb.VTGate, error) { + if d.gates.shouldErr { + return nil, assert.AnError + } + + if len(tags) == 0 { + results := make([]*vtadminpb.VTGate, 0, len(d.gates.byName)) + for _, gate := range d.gates.byName { + results = append(results, gate) + } + + return results, nil + } + + set := d.gates.byName + + for _, tag := range tags { + intermediate := map[string]*vtadminpb.VTGate{} + + gates, ok := d.gates.byTag[tag] + if !ok { + return []*vtadminpb.VTGate{}, nil + } + + for _, g := range gates { + if _, ok := set[g.Hostname]; ok { + intermediate[g.Hostname] = g + } + } + + set = intermediate + } + + results := make([]*vtadminpb.VTGate, 0, len(set)) + + for _, gate := range set { + results = append(results, gate) + } + + return results, nil +} + +// DiscoverVTGate is part of the discovery.Discovery interface. +func (d *Fake) DiscoverVTGate(ctx context.Context, tags []string) (*vtadminpb.VTGate, error) { + gates, err := d.DiscoverVTGates(ctx, tags) + if err != nil { + return nil, err + } + + if len(gates) == 0 { + return nil, assert.AnError + } + + return gates[rand.Intn(len(gates))], nil +} + +// DiscoverVTGateAddr is part of the discovery.Discovery interface. +func (d *Fake) DiscoverVTGateAddr(ctx context.Context, tags []string) (string, error) { + gate, err := d.DiscoverVTGate(ctx, tags) + if err != nil { + return "", err + } + + return gate.Hostname, nil +} diff --git a/go/vt/vtadmin/cluster/discovery/fakediscovery/discovery_test.go b/go/vt/vtadmin/cluster/discovery/fakediscovery/discovery_test.go new file mode 100644 index 00000000000..25bdd0f57ae --- /dev/null +++ b/go/vt/vtadmin/cluster/discovery/fakediscovery/discovery_test.go @@ -0,0 +1,71 @@ +/* +Copyright 2020 The Vitess Authors. + +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 fakediscovery + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +func TestDiscoverVTGates(t *testing.T) { + fake := New() + gates := []*vtadminpb.VTGate{ + { + Hostname: "gate1", + }, + { + Hostname: "gate2", + }, + { + Hostname: "gate3", + }, + } + + fake.AddTaggedGates(nil, gates...) + fake.AddTaggedGates([]string{"tag1:val1"}, gates[0], gates[1]) + fake.AddTaggedGates([]string{"tag2:val2"}, gates[0], gates[2]) + + actual, err := fake.DiscoverVTGates(context.Background(), nil) + assert.NoError(t, err) + assert.ElementsMatch(t, gates, actual) + + actual, err = fake.DiscoverVTGates(context.Background(), []string{"tag1:val1"}) + assert.NoError(t, err) + assert.ElementsMatch(t, []*vtadminpb.VTGate{gates[0], gates[1]}, actual) + + actual, err = fake.DiscoverVTGates(context.Background(), []string{"tag2:val2"}) + assert.NoError(t, err) + assert.ElementsMatch(t, []*vtadminpb.VTGate{gates[0], gates[2]}, actual) + + actual, err = fake.DiscoverVTGates(context.Background(), []string{"tag1:val1", "tag2:val2"}) + assert.NoError(t, err) + assert.ElementsMatch(t, []*vtadminpb.VTGate{gates[0]}, actual) + + actual, err = fake.DiscoverVTGates(context.Background(), []string{"differentTag:val"}) + assert.NoError(t, err) + assert.Equal(t, []*vtadminpb.VTGate{}, actual) + + fake.SetGatesError(true) + + actual, err = fake.DiscoverVTGates(context.Background(), nil) + assert.Error(t, err) + assert.Nil(t, actual) +} diff --git a/go/vt/vtadmin/cluster/file_config.go b/go/vt/vtadmin/cluster/file_config.go new file mode 100644 index 00000000000..144670e154d --- /dev/null +++ b/go/vt/vtadmin/cluster/file_config.go @@ -0,0 +1,138 @@ +/* +Copyright 2020 The Vitess Authors. + +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 cluster + +import ( + "io/ioutil" + "strings" + + "gopkg.in/yaml.v2" +) + +// FileConfig represents the structure of a set of cluster configs on disk. It +// contains both a default config, and cluster-specific overrides. Currently +// only YAML config files are supported. +// +// A valid config looks like: +// defaults: +// discovery: k8s +// clusters: +// clusterID1: +// name: clusterName1 +// discovery-k8s-some-flag: some-val +// clusterID2: +// name: clusterName2 +// discovery: consul +type FileConfig struct { + Defaults Config + Clusters map[string]Config +} + +// UnmarshalYAML is part of the yaml.Unmarshaler interface. +func (fc *FileConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + tmp := struct { + Defaults Config + Clusters map[string]Config + }{ + Defaults: fc.Defaults, + Clusters: fc.Clusters, + } + + if err := unmarshal(&tmp); err != nil { + return err + } + + fc.Defaults = tmp.Defaults + fc.Clusters = make(map[string]Config, len(tmp.Clusters)) + + for id, cfg := range tmp.Clusters { + fc.Clusters[id] = cfg.Merge(Config{ID: id}) + } + + return nil +} + +// String is part of the flag.Value interface. +func (fc *FileConfig) String() string { + buf := strings.Builder{} + + // inlining this produces "String is not in the method set of ClustersFlag" + cf := ClustersFlag(fc.Clusters) + + buf.WriteString("{defaults: ") + buf.WriteString(fc.Defaults.String()) + buf.WriteString(", clusters: ") + buf.WriteString(cf.String()) + buf.WriteString("}") + + return buf.String() +} + +// Type is part of the pflag.Value interface. +func (fc *FileConfig) Type() string { + return "cluster.FileConfig" +} + +// Set is part of the flag.Value interface. It loads the file configuration +// found at the path passed to the flag. +func (fc *FileConfig) Set(value string) error { + data, err := ioutil.ReadFile(value) + if err != nil { + return err + } + + return yaml.Unmarshal(data, fc) +} + +// Combine combines a FileConfig with a default Config and a ClustersFlag (each +// defined on the command-line) into a slice of final Configs that are suitable +// to use for cluster creation. +// +// Combination uses the following precedence: +// 1. Command-line cluster-specific overrides. +// 2. File-based cluster-specific overrides. +// 3. Command-line cluster defaults. +// 4. File-based cluster defaults. +func (fc *FileConfig) Combine(defaults Config, clusters map[string]Config) []Config { + configs := make([]Config, 0, len(clusters)) + merged := map[string]bool{} + + combinedDefaults := fc.Defaults.Merge(defaults) + + for name, cfg := range fc.Clusters { + merged[name] = true + + override, ok := clusters[name] + if !ok { + configs = append(configs, combinedDefaults.Merge(cfg)) + continue + } + + combinedOverrides := cfg.Merge(override) + configs = append(configs, combinedDefaults.Merge(combinedOverrides)) + } + + for name, cfg := range clusters { + if _, ok := merged[name]; ok { + continue + } + + configs = append(configs, combinedDefaults.Merge(cfg)) + } + + return configs +} diff --git a/go/vt/vtadmin/cluster/file_config_test.go b/go/vt/vtadmin/cluster/file_config_test.go new file mode 100644 index 00000000000..00e3108db3c --- /dev/null +++ b/go/vt/vtadmin/cluster/file_config_test.go @@ -0,0 +1,249 @@ +/* +Copyright 2020 The Vitess Authors. + +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 cluster + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" +) + +func TestFileConfigUnmarshalYAML(t *testing.T) { + tests := []struct { + name string + yaml string + config FileConfig + err error + }{ + { + name: "simple", + yaml: `defaults: + discovery: consul + discovery-consul-vtgate-datacenter-tmpl: "dev-{{ .Cluster }}" + discovery-consul-vtgate-service-name: vtgate-svc + discovery-consul-vtgate-pool-tag: type + discovery-consul-vtgate-cell-tag: zone + discovery-consul-vtgate-addr-tmpl: "{{ .Name }}.example.com:15999" + +clusters: + c1: + name: testcluster1 + discovery-consul-vtgate-datacenter-tmpl: "dev-{{ .Cluster }}-test" + c2: + name: devcluster`, + config: FileConfig{ + Defaults: Config{ + DiscoveryImpl: "consul", + DiscoveryFlagsByImpl: map[string]map[string]string{ + "consul": { + "vtgate-datacenter-tmpl": "dev-{{ .Cluster }}", + "vtgate-service-name": "vtgate-svc", + "vtgate-pool-tag": "type", + "vtgate-cell-tag": "zone", + "vtgate-addr-tmpl": "{{ .Name }}.example.com:15999", + }, + }, + }, + Clusters: map[string]Config{ + "c1": { + ID: "c1", + Name: "testcluster1", + DiscoveryFlagsByImpl: map[string]map[string]string{ + "consul": { + "vtgate-datacenter-tmpl": "dev-{{ .Cluster }}-test", + }, + }, + VtSQLFlags: map[string]string{}, + }, + "c2": { + ID: "c2", + Name: "devcluster", + DiscoveryFlagsByImpl: map[string]map[string]string{}, + VtSQLFlags: map[string]string{}, + }, + }, + }, + err: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := FileConfig{ + Defaults: Config{ + DiscoveryFlagsByImpl: map[string]map[string]string{}, + }, + Clusters: map[string]Config{}, + } + + err := yaml.Unmarshal([]byte(tt.yaml), &cfg) + if tt.err != nil { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.config, cfg) + }) + } +} + +func TestCombine(t *testing.T) { + tests := []struct { + name string + fc FileConfig + defaults Config + configs map[string]Config + expected []Config + }{ + { + name: "default overrides file", + fc: FileConfig{ + Defaults: Config{ + DiscoveryImpl: "consul", + DiscoveryFlagsByImpl: map[string]map[string]string{ + "consul": { + "vtgate-datacenter-tmpl": "dev-{{ .Cluster }}", + }, + }, + }, + }, + defaults: Config{ + DiscoveryImpl: "zk", + DiscoveryFlagsByImpl: map[string]map[string]string{}, + }, + configs: map[string]Config{ + "1": { + ID: "1", + Name: "one", + }, + "2": { + ID: "2", + Name: "two", + DiscoveryFlagsByImpl: map[string]map[string]string{ + "consul": { + "vtgate-datacenter-tmpl": "dev-{{ .Cluster }}-test", + }, + }, + }, + }, + expected: []Config{ + { + ID: "1", + Name: "one", + DiscoveryImpl: "zk", + DiscoveryFlagsByImpl: map[string]map[string]string{ + "consul": { + "vtgate-datacenter-tmpl": "dev-{{ .Cluster }}", + }, + }, + VtSQLFlags: map[string]string{}, + }, + { + ID: "2", + Name: "two", + DiscoveryImpl: "zk", + DiscoveryFlagsByImpl: map[string]map[string]string{ + "consul": { + "vtgate-datacenter-tmpl": "dev-{{ .Cluster }}-test", + }, + }, + VtSQLFlags: map[string]string{}, + }, + }, + }, + { + name: "mixed", + fc: FileConfig{ + Defaults: Config{ + DiscoveryImpl: "consul", + }, + Clusters: map[string]Config{ + "c1": { + ID: "c1", + Name: "cluster1", + }, + "c2": { + ID: "c2", + Name: "cluster2", + }, + }, + }, + defaults: Config{ + DiscoveryFlagsByImpl: map[string]map[string]string{ + "zk": { + "flag": "val", + }, + }, + }, + configs: map[string]Config{ + "c1": { + ID: "c1", + Name: "cluster1", + }, + "c3": { + ID: "c3", + Name: "cluster3", + }, + }, + expected: []Config{ + { + ID: "c1", + Name: "cluster1", + DiscoveryImpl: "consul", + DiscoveryFlagsByImpl: map[string]map[string]string{ + "zk": { + "flag": "val", + }, + }, + VtSQLFlags: map[string]string{}, + }, + { + ID: "c2", + Name: "cluster2", + DiscoveryImpl: "consul", + DiscoveryFlagsByImpl: map[string]map[string]string{ + "zk": { + "flag": "val", + }, + }, + VtSQLFlags: map[string]string{}, + }, + { + ID: "c3", + Name: "cluster3", + DiscoveryImpl: "consul", + DiscoveryFlagsByImpl: map[string]map[string]string{ + "zk": { + "flag": "val", + }, + }, + VtSQLFlags: map[string]string{}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := tt.fc.Combine(tt.defaults, tt.configs) + assert.ElementsMatch(t, tt.expected, actual) + }) + } +} diff --git a/go/vt/vtadmin/cluster/flags.go b/go/vt/vtadmin/cluster/flags.go new file mode 100644 index 00000000000..bb9b180183a --- /dev/null +++ b/go/vt/vtadmin/cluster/flags.go @@ -0,0 +1,184 @@ +/* +Copyright 2020 The Vitess Authors. + +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 cluster + +import ( + "regexp" + "strings" +) + +// FlagsByImpl groups a set of flags by discovery implementation. Its mapping is +// impl_name=>flag=>value. +type FlagsByImpl map[string]map[string]string + +// Merge applies the flags in the parameter to the receiver, conflicts are +// resolved in favor of the parameter and not the receiver. +func (base *FlagsByImpl) Merge(override map[string]map[string]string) { + if (*base) == nil { + *base = map[string]map[string]string{} + } + + for impl, flags := range override { + _, ok := (*base)[impl] + if !ok { + (*base)[impl] = map[string]string{} + } + + for k, v := range flags { + (*base)[impl][k] = v + } + } +} + +// ClustersFlag implements flag.Value allowing multiple occurrences of a flag to +// be accumulated into a map. +type ClustersFlag map[string]Config + +// String is part of the flag.Value interface. +func (cf *ClustersFlag) String() string { + buf := strings.Builder{} + + buf.WriteString("[") + + i := 0 + + for _, cfg := range *cf { + buf.WriteString(cfg.String()) + + if i < len(*cf)-1 { + buf.WriteString(" ") + } + + i++ + } + + buf.WriteString("]") + + return buf.String() +} + +// Type is part of the pflag.Value interface. +func (cf *ClustersFlag) Type() string { + return "cluster.ClustersFlag" +} + +// Set is part of the flag.Value interface. It merges the parsed config into the +// map, allowing ClustersFlag to power a repeated flag. See (*Config).Set for +// details on flag parsing. +func (cf *ClustersFlag) Set(value string) error { + if (*cf) == nil { + (*cf) = map[string]Config{} + } + + cfg := Config{ + DiscoveryFlagsByImpl: map[string]map[string]string{}, + } + + if err := parseFlag(&cfg, value); err != nil { + return err + } + + // Merge a potentially existing config for the same cluster ID. + c, ok := (*cf)[cfg.ID] + if !ok { + // If we don't have an existing config, create an empty one to "merge" + // into. + c = Config{} + } + + (*cf)[cfg.ID] = cfg.Merge(c) + + return nil +} + +// nolint:gochecknoglobals +var discoveryFlagRegexp = regexp.MustCompile(`^discovery-(?P\w+)-(?P.+)$`) + +func parseFlag(cfg *Config, value string) error { + args := strings.Split(value, ",") + for _, arg := range args { + var ( + name string + val string + ) + + if strings.Contains(arg, "=") { + parts := strings.Split(arg, "=") + name = parts[0] + val = strings.Join(parts[1:], "=") + } else { + name = arg + val = "true" + } + + if err := parseOne(cfg, name, val); err != nil { + return err + } + } + + return nil +} + +func parseOne(cfg *Config, name string, val string) error { + switch name { + case "id": + cfg.ID = val + case "name": + cfg.Name = val + case "discovery": + cfg.DiscoveryImpl = val + default: + if strings.HasPrefix(name, "vtsql-") { + if cfg.VtSQLFlags == nil { + cfg.VtSQLFlags = map[string]string{} + } + + cfg.VtSQLFlags[strings.TrimPrefix(name, "vtsql-")] = val + + return nil + } + + match := discoveryFlagRegexp.FindStringSubmatch(name) + if match == nil { + // not a discovery flag + return nil + } + + var impl, flag string + + for i, g := range discoveryFlagRegexp.SubexpNames() { + switch g { + case "impl": + impl = match[i] + case "flag": + flag = match[i] + } + } + + if cfg.DiscoveryFlagsByImpl == nil { + cfg.DiscoveryFlagsByImpl = map[string]map[string]string{} + } + + if cfg.DiscoveryFlagsByImpl[impl] == nil { + cfg.DiscoveryFlagsByImpl[impl] = map[string]string{} + } + + cfg.DiscoveryFlagsByImpl[impl][flag] = val + } + + return nil +} diff --git a/go/vt/vtadmin/cluster/flags_test.go b/go/vt/vtadmin/cluster/flags_test.go new file mode 100644 index 00000000000..48867ac9441 --- /dev/null +++ b/go/vt/vtadmin/cluster/flags_test.go @@ -0,0 +1,105 @@ +/* +Copyright 2020 The Vitess Authors. + +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 cluster + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMergeFlagsByImpl(t *testing.T) { + var NilMap map[string]map[string]string + + tests := []struct { + name string + base map[string]map[string]string + in map[string]map[string]string + expected map[string]map[string]string + }{ + { + name: "nil", + base: nil, + in: nil, + expected: map[string]map[string]string{}, + }, + { + name: "wrapped nil", + base: NilMap, + in: NilMap, + expected: map[string]map[string]string{}, + }, + { + name: "all overrides", + base: nil, + in: map[string]map[string]string{ + "consul": { + "flag1": "value1", + }, + }, + expected: map[string]map[string]string{ + "consul": { + "flag1": "value1", + }, + }, + }, + { + name: "all defaults", + base: map[string]map[string]string{ + "consul": { + "flag1": "value1", + }, + }, + in: nil, + expected: map[string]map[string]string{ + "consul": { + "flag1": "value1", + }, + }, + }, + { + name: "mixed", + base: map[string]map[string]string{ + "consul": { + "flag1": "value1", + "flag2": "value2", + }, + "other": {}, + }, + in: map[string]map[string]string{ + "consul": { + "flag1": "othervalue", + }, + }, + expected: map[string]map[string]string{ + "consul": { + "flag1": "othervalue", + "flag2": "value2", + }, + "other": {}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + flags := FlagsByImpl(test.base) + flags.Merge(test.in) + assert.Equal(t, FlagsByImpl(test.expected), flags) + }) + } +} diff --git a/go/vt/vtadmin/errors.go b/go/vt/vtadmin/errors.go new file mode 100644 index 00000000000..60356e9bde9 --- /dev/null +++ b/go/vt/vtadmin/errors.go @@ -0,0 +1,30 @@ +/* +Copyright 2020 The Vitess Authors. + +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 vtadmin + +import "errors" + +var ( + // ErrAmbiguousTablet occurs when more than one tablet is found for a given + // set of filter criteria. + ErrAmbiguousTablet = errors.New("multiple tablets found") + // ErrNoTablet occurs when a tablet cannot be found for a given set of + // filter criteria. + ErrNoTablet = errors.New("no such tablet") + // ErrUnsupportedCluster occurs when a cluster parameter is invalid. + ErrUnsupportedCluster = errors.New("unsupported cluster(s)") +) diff --git a/go/vt/vtadmin/errors/typed_error.go b/go/vt/vtadmin/errors/typed_error.go new file mode 100644 index 00000000000..f5174cbaeae --- /dev/null +++ b/go/vt/vtadmin/errors/typed_error.go @@ -0,0 +1,68 @@ +/* +Copyright 2020 The Vitess Authors. + +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 errors + +import ( + "fmt" + "strings" +) + +// TypedError defines the behavior needed to strongly-type an error into an +// http response. +type TypedError interface { + Error() string + Code() string + Details() interface{} + HTTPStatus() int +} + +// Unknown is the generic error, used when a more specific error is either +// unspecified or inappropriate. +type Unknown struct { + Err error + ErrDetails interface{} +} + +func (e *Unknown) Error() string { return e.Err.Error() } +func (e *Unknown) Code() string { return "unknown" } +func (e *Unknown) Details() interface{} { return e.ErrDetails } +func (e *Unknown) HTTPStatus() int { return 500 } + +// ErrInvalidCluster is returned when a cluster parameter, either in a route or +// as a query param, is invalid. +type ErrInvalidCluster struct { + Err error +} + +func (e *ErrInvalidCluster) Error() string { return e.Err.Error() } +func (e *ErrInvalidCluster) Code() string { return "invalid cluster" } +func (e *ErrInvalidCluster) Details() interface{} { return nil } +func (e *ErrInvalidCluster) HTTPStatus() int { return 400 } + +// MissingParams is returned when an HTTP handler requires parameters that were +// not provided. +type MissingParams struct { + Params []string +} + +func (e *MissingParams) Error() string { + return fmt.Sprintf("missing required params: %s", strings.Join(e.Params, ", ")) +} + +func (e *MissingParams) Code() string { return "missing params" } +func (e *MissingParams) Details() interface{} { return nil } +func (e *MissingParams) HTTPStatus() int { return 400 } diff --git a/go/vt/vtadmin/grpcserver/server.go b/go/vt/vtadmin/grpcserver/server.go new file mode 100644 index 00000000000..143a8308c4b --- /dev/null +++ b/go/vt/vtadmin/grpcserver/server.go @@ -0,0 +1,241 @@ +/* +Copyright 2020 The Vitess Authors. + +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 grpcserver + +import ( + "fmt" + "net" + "net/http" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/gorilla/mux" + grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware" + grpc_recovery "github.com/grpc-ecosystem/go-grpc-middleware/recovery" + otgrpc "github.com/grpc-ecosystem/go-grpc-middleware/tracing/opentracing" + grpc_prometheus "github.com/grpc-ecosystem/go-grpc-prometheus" + "github.com/opentracing/opentracing-go" + "github.com/soheilhy/cmux" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + "google.golang.org/grpc/reflection" + "vitess.io/vitess/go/vt/log" + "vitess.io/vitess/go/vt/vterrors" + + healthpb "google.golang.org/grpc/health/grpc_health_v1" + vtrpcpb "vitess.io/vitess/go/vt/proto/vtrpc" +) + +// Options defines the set of configurations for a gRPC server. +type Options struct { + // Addr is the network address to listen on. + Addr string + // CMuxReadTimeout bounds the amount of time spent muxing connections between + // gRPC and HTTP. A zero-value specifies unbounded muxing. + CMuxReadTimeout time.Duration + // LameDuckDuration specifies the length of the lame duck period during + // graceful shutdown. If non-zero, the Server will mark itself unhealthy to + // stop new incoming connections while continuing to serve existing + // connections. + LameDuckDuration time.Duration + // AllowReflection specifies whether to register the gRPC server for + // reflection. This is required to use with tools like grpc_cli. + AllowReflection bool + // EnableTracing specifies whether to install opentracing interceptors on + // the gRPC server. + EnableTracing bool +} + +// Server provides a multiplexed gRPC/HTTP server. +type Server struct { + name string + + gRPCServer *grpc.Server + healthServer *health.Server + router *mux.Router + serving bool + m sync.RWMutex // this locks the serving bool + + opts Options +} + +// New returns a new server. See Options for documentation on configuration +// options. +// +// The underlying gRPC server always has the following interceptors: +// - prometheus +// - recovery: this handles recovering from panics. +func New(name string, opts Options) *Server { + streamInterceptors := []grpc.StreamServerInterceptor{grpc_prometheus.StreamServerInterceptor} + unaryInterceptors := []grpc.UnaryServerInterceptor{grpc_prometheus.UnaryServerInterceptor} + + if opts.EnableTracing { + tracer := opentracing.GlobalTracer() + streamInterceptors = append(streamInterceptors, otgrpc.StreamServerInterceptor(otgrpc.WithTracer(tracer))) + unaryInterceptors = append(unaryInterceptors, otgrpc.UnaryServerInterceptor(otgrpc.WithTracer(tracer))) + } + + recoveryHandler := grpc_recovery.WithRecoveryHandler(func(p interface{}) (err error) { + return vterrors.Errorf(vtrpcpb.Code_INTERNAL, "panic triggered: %v", p) + }) + + streamInterceptors = append(streamInterceptors, grpc_recovery.StreamServerInterceptor(recoveryHandler)) + unaryInterceptors = append(unaryInterceptors, grpc_recovery.UnaryServerInterceptor(recoveryHandler)) + + gserv := grpc.NewServer( + grpc.StreamInterceptor(grpc_middleware.ChainStreamServer(streamInterceptors...)), + grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer(unaryInterceptors...)), + ) + + if opts.AllowReflection { + reflection.Register(gserv) + } + + healthServer := health.NewServer() + healthpb.RegisterHealthServer(gserv, healthServer) + + return &Server{ + name: name, + gRPCServer: gserv, + healthServer: healthServer, + router: mux.NewRouter(), + opts: opts, + } +} + +// GRPCServer returns the gRPC Server. +func (s *Server) GRPCServer() *grpc.Server { + return s.gRPCServer +} + +// Router returns the mux.Router powering the HTTP side of the server. +func (s *Server) Router() *mux.Router { + return s.router +} + +// MustListenAndServe calls ListenAndServe and panics if an error occurs. +func (s *Server) MustListenAndServe() { + if err := s.ListenAndServe(); err != nil { + panic(err) + } +} + +// listenFunc is extracted to mock out in tests. +var listenFunc = net.Listen // nolint:gochecknoglobals + +// ListenAndServe sets up a listener, multiplexes it into gRPC and non-gRPC +// requests, and binds the gRPC server and mux.Router to them, respectively. It +// then installs a signal handler on SIGTERM and SIGQUIT, and runs until either +// a signal or an unrecoverable error occurs. +// +// On shutdown, it may begin a lame duck period (see Options) before beginning +// a graceful shutdown of the gRPC server and closing listeners. +func (s *Server) ListenAndServe() error { // nolint:funlen + lis, err := listenFunc("tcp", s.opts.Addr) + if err != nil { + return err + } + defer lis.Close() + + lmux := cmux.New(lis) + + if s.opts.CMuxReadTimeout > 0 { + lmux.SetReadTimeout(s.opts.CMuxReadTimeout) + } + + grpcLis := lmux.MatchWithWriters(cmux.HTTP2MatchHeaderFieldSendSettings("content-type", "application/grpc")) + anyLis := lmux.Match(cmux.Any()) + + shutdown := make(chan error, 16) + + signals := make(chan os.Signal, 8) + signal.Notify(signals, syscall.SIGTERM, syscall.SIGQUIT) + + // listen for signals + go func() { + sig := <-signals + err := fmt.Errorf("received signal: %v", sig) // nolint:goerr113 + log.Warning(err) + shutdown <- err + }() + + // Start the servers + go func() { + err := s.gRPCServer.Serve(grpcLis) + err = fmt.Errorf("grpc server stopped: %w", err) + log.Warning(err) + shutdown <- err + }() + + go func() { + err := http.Serve(anyLis, s.router) + err = fmt.Errorf("http server stopped: %w", err) + log.Warning(err) + shutdown <- err + }() + + // Start muxing connections + go func() { + err := lmux.Serve() + err = fmt.Errorf("listener closed: %w", err) + log.Warning(err) + shutdown <- err + }() + + // (TODO:@amason) Figure out a good abstraction to have other services + // register themselves. + s.healthServer.SetServingStatus("grpc.health.v1.Health", healthpb.HealthCheckResponse_SERVING) + + s.setServing(true) + log.Infof("server %s listening on %s", s.name, s.opts.Addr) + + reason := <-shutdown + log.Warningf("graceful shutdown triggered by: %v", reason) + + if s.opts.LameDuckDuration > 0 { + log.Infof("entering lame duck period for %v", s.opts.LameDuckDuration) + s.healthServer.Shutdown() + time.Sleep(s.opts.LameDuckDuration) + } else { + log.Infof("lame duck disabled") + } + + log.Info("beginning graceful shutdown") + s.gRPCServer.GracefulStop() + log.Info("graceful shutdown complete") + + s.setServing(false) + + return nil +} + +func (s *Server) setServing(state bool) { + s.m.Lock() + defer s.m.Unlock() + + s.serving = state +} + +func (s *Server) isServing() bool { + s.m.RLock() + defer s.m.RUnlock() + + return s.serving +} diff --git a/go/vt/vtadmin/grpcserver/server_test.go b/go/vt/vtadmin/grpcserver/server_test.go new file mode 100644 index 00000000000..29774476090 --- /dev/null +++ b/go/vt/vtadmin/grpcserver/server_test.go @@ -0,0 +1,145 @@ +/* +Copyright 2020 The Vitess Authors. + +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 grpcserver + +import ( + "context" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "golang.org/x/net/nettest" + "google.golang.org/grpc" + + healthpb "google.golang.org/grpc/health/grpc_health_v1" +) + +func TestServer(t *testing.T) { + lis, err := nettest.NewLocalListener("tcp") + listenFunc = func(network, address string) (net.Listener, error) { + return lis, err + } + + defer lis.Close() + + s := New("testservice", Options{ + EnableTracing: true, + AllowReflection: true, + CMuxReadTimeout: time.Second, + }) + + go func() { err := s.ListenAndServe(); assert.NoError(t, err) }() + + readyCh := make(chan bool) + + go func() { + for !s.isServing() { + } + readyCh <- true + }() + + serveStart := time.Now() + select { + case <-readyCh: + case serveStop := <-time.After(time.Millisecond * 500): + t.Errorf("server did not start within %s", serveStop.Sub(serveStart)) + return + } + close(readyCh) + + conn, err := grpc.Dial(lis.Addr().String(), grpc.WithInsecure(), grpc.WithBlock()) + assert.NoError(t, err) + + defer conn.Close() + + healthclient := healthpb.NewHealthClient(conn) + resp, err := healthclient.Check(context.Background(), &healthpb.HealthCheckRequest{Service: "grpc.health.v1.Health"}) + assert.NoError(t, err) + assert.NotNil(t, resp) +} + +func TestLameduck(t *testing.T) { + lis, err := nettest.NewLocalListener("tcp") + listenFunc = func(network, address string) (net.Listener, error) { + return lis, err + } + + ldd := time.Millisecond * 50 + + s := New("testservice", Options{LameDuckDuration: ldd}) + + go func() { err := s.ListenAndServe(); assert.NoError(t, err) }() + + readyCh := make(chan bool) + + go func() { + for !s.isServing() { + } + readyCh <- true + }() + + serveStart := time.Now() + select { + case <-readyCh: + case serveStop := <-time.After(time.Millisecond * 500): + t.Errorf("server did not start within %s", serveStop.Sub(serveStart)) + return + } + + stoppedCh := make(chan bool) + + go func() { + for s.isServing() { + } + stoppedCh <- true + }() + + shutdownStart := time.Now() + + lis.Close() + + select { + case <-stoppedCh: + case <-time.After(ldd): + } + + shutdownDuration := time.Since(shutdownStart) + assert.LessOrEqual(t, int64(ldd), int64(shutdownDuration), + "should have taken at least %s to shutdown, took only %s", ldd, shutdownDuration) +} + +func TestError(t *testing.T) { + listenFunc = func(network, address string) (net.Listener, error) { return nil, assert.AnError } + s := New("testservice", Options{}) + errCh := make(chan error) + + // This has to happen in a goroutine. In normal operation, this function + // blocks until externally signalled, and we don't want to hold up the + // tests. + go func() { + errCh <- s.ListenAndServe() + }() + + start := time.Now() + select { + case err := <-errCh: + assert.Error(t, err) + case ti := <-time.After(time.Millisecond * 10): + assert.Fail(t, "timed out waiting for error after %s", ti.Sub(start)) + } +} diff --git a/go/vt/vtadmin/http/api.go b/go/vt/vtadmin/http/api.go new file mode 100644 index 00000000000..00a6c933e02 --- /dev/null +++ b/go/vt/vtadmin/http/api.go @@ -0,0 +1,71 @@ +/* +Copyright 2020 The Vitess Authors. + +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 http + +import ( + "context" + "net/http" + + "vitess.io/vitess/go/trace" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +// Options defines the set of configurations for an HTTP API server. +type Options struct { + // CORSOrigins is the list of origins to allow via CORS. An empty or nil + // slice disables CORS entirely. + CORSOrigins []string + // EnableTracing specifies whether to install a tracing middleware on the + // API subrouter. + EnableTracing bool + // DisableCompression specifies whether to turn off gzip compression for API + // endpoints. It is named as the negative (as opposed to EnableTracing) so + // the zero value has compression enabled. + DisableCompression bool +} + +// API is used to power HTTP endpoint wrappers to the VTAdminServer interface. +type API struct { + server vtadminpb.VTAdminServer +} + +// NewAPI returns an HTTP API backed by the given VTAdminServer implementation. +func NewAPI(server vtadminpb.VTAdminServer) *API { + return &API{server: server} +} + +// VTAdminHandler is an HTTP endpoint handler that takes, via injection, +// everything needed to implement a JSON API response. +type VTAdminHandler func(ctx context.Context, r Request, api *API) *JSONResponse + +// Adapt converts a VTAdminHandler into an http.HandlerFunc. It deals with +// wrapping the request in a wrapper for some convenience functions and starts +// a new context, after extracting any potential spans that were set by an +// upstream middleware in the request context. +func (api *API) Adapt(handler VTAdminHandler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + + span, _ := trace.FromContext(r.Context()) + if span != nil { + ctx = trace.NewContext(ctx, span) + } + + handler(ctx, Request{r}, api).Write(w) + } +} diff --git a/go/vt/vtadmin/http/gates.go b/go/vt/vtadmin/http/gates.go new file mode 100644 index 00000000000..1cbcc6a749d --- /dev/null +++ b/go/vt/vtadmin/http/gates.go @@ -0,0 +1,32 @@ +/* +Copyright 2020 The Vitess Authors. + +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 http + +import ( + "context" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +// GetGates implements the http wrapper for /gates[?cluster=[&cluster=]]. +func GetGates(ctx context.Context, r Request, api *API) *JSONResponse { + gates, err := api.server.GetGates(ctx, &vtadminpb.GetGatesRequest{ + ClusterIds: r.URL.Query()["cluster"], + }) + + return NewJSONResponse(gates, err) +} diff --git a/go/vt/vtadmin/http/handlers/trace.go b/go/vt/vtadmin/http/handlers/trace.go new file mode 100644 index 00000000000..f4758e75207 --- /dev/null +++ b/go/vt/vtadmin/http/handlers/trace.go @@ -0,0 +1,63 @@ +/* +Copyright 2020 The Vitess Authors. + +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 handlers + +import ( + "net/http" + + "github.com/gorilla/mux" + "vitess.io/vitess/go/trace" +) + +// TraceHandler is a mux.MiddlewareFunc which creates a span with the route's +// name, as set by (mux.*Route).Name(), embeds it in the request context, and invokes +// the next middleware in the chain. +// +// It also annotates the span with the route_path_template, if it exists, and +// the route_uri. To add additional spans, extract the span in your +// VTAdminHTTPHandler like: +// +// func Handler(ctx context.Context, r Request, api *API) *JSONResponse { +// span, _ := trace.FromContext(ctx) +// span.Annotate("foo", "bar") +// +// return NewJSONResponse(api.Something(ctx)) +// } +// +// An unnamed route will get a span named "vtadmin:http:". +func TraceHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + route := mux.CurrentRoute(r) + + name := route.GetName() + if name == "" { + next.ServeHTTP(w, r) + return + } + + span, ctx := trace.NewSpan(r.Context(), "vtadmin:http:"+name) + defer span.Finish() + + span.Annotate("route_uri", r.RequestURI) + + if tmpl, err := route.GetPathTemplate(); err != nil { + span.Annotate("route_path_template", tmpl) + } + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/go/vt/vtadmin/http/request.go b/go/vt/vtadmin/http/request.go new file mode 100644 index 00000000000..6c0b7181c47 --- /dev/null +++ b/go/vt/vtadmin/http/request.go @@ -0,0 +1,33 @@ +/* +Copyright 2020 The Vitess Authors. + +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 http + +import ( + "net/http" + + "github.com/gorilla/mux" +) + +// Request wraps an *http.Request to provide some convenience functions for +// accessing request data. +type Request struct{ *http.Request } + +// Vars returns the route variables in a request, if any, as defined by +// gorilla/mux. +func (r Request) Vars() map[string]string { + return mux.Vars(r.Request) +} diff --git a/go/vt/vtadmin/http/response.go b/go/vt/vtadmin/http/response.go new file mode 100644 index 00000000000..8e3bab177ee --- /dev/null +++ b/go/vt/vtadmin/http/response.go @@ -0,0 +1,98 @@ +/* +Copyright 2020 The Vitess Authors. + +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 http + +import ( + "encoding/json" + "fmt" + "net/http" + + "vitess.io/vitess/go/vt/vtadmin/errors" +) + +// JSONResponse represents a generic response object. +type JSONResponse struct { + Result interface{} `json:"result,omitempty"` + Error *errorBody `json:"error,omitempty"` + Ok bool `json:"ok"` + httpStatus int +} + +type errorBody struct { + Message string `json:"message"` + Code string `json:"code"` + Details interface{} `json:"details,omitempty"` +} + +// NewJSONResponse returns a JSONResponse for the given result and error. If err +// is non-nil, and implements errors.TypedError, the HTTP status code and +// message are provided by the error. If not, the code and message fallback to +// 500 unknown. +func NewJSONResponse(value interface{}, err error) *JSONResponse { + if err != nil { + switch e := err.(type) { + case errors.TypedError: + return typedErrorJSONResponse(e) + default: + return typedErrorJSONResponse(&errors.Unknown{Err: e}) + } + } + + return &JSONResponse{ + Result: value, + Error: nil, + Ok: true, + httpStatus: 200, + } +} + +// WithHTTPStatus forces a response to be used for the JSONResponse. +func (r *JSONResponse) WithHTTPStatus(code int) *JSONResponse { + r.httpStatus = code + return r +} + +func typedErrorJSONResponse(v errors.TypedError) *JSONResponse { + return &JSONResponse{ + Error: &errorBody{ + Message: v.Error(), + Details: v.Details(), + Code: v.Code(), + }, + Ok: false, + httpStatus: v.HTTPStatus(), + } +} + +// Write marshals a JSONResponse into the http response. +func (r *JSONResponse) Write(w http.ResponseWriter) { + b, err := json.Marshal(r) + if err != nil { + w.WriteHeader(500) + // A bit clunky but if we already failed to marshal JSON, let's do it by hand. + msgFmt := `{"error": {"code": "unknown_error", "message": %q}}` + fmt.Fprintf(w, msgFmt, err.Error()) + + return + } + + if r.httpStatus != 200 { + w.WriteHeader(r.httpStatus) + } + + fmt.Fprintf(w, "%s", b) +} diff --git a/go/vt/vtadmin/http/tablets.go b/go/vt/vtadmin/http/tablets.go new file mode 100644 index 00000000000..7133dfc3c51 --- /dev/null +++ b/go/vt/vtadmin/http/tablets.go @@ -0,0 +1,44 @@ +/* +Copyright 2020 The Vitess Authors. + +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 http + +import ( + "context" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +// GetTablets implements the http wrapper for /tablets[?cluster=[&cluster=]]. +func GetTablets(ctx context.Context, r Request, api *API) *JSONResponse { + tablets, err := api.server.GetTablets(ctx, &vtadminpb.GetTabletsRequest{ + ClusterIds: r.URL.Query()["cluster"], + }) + + return NewJSONResponse(tablets, err) +} + +// GetTablet implements the http wrapper for /tablet/{tablet}[?cluster=[&cluster=]]. +func GetTablet(ctx context.Context, r Request, api *API) *JSONResponse { + vars := r.Vars() + + tablet, err := api.server.GetTablet(ctx, &vtadminpb.GetTabletRequest{ + Hostname: vars["tablet"], + ClusterIds: r.URL.Query()["cluster"], + }) + + return NewJSONResponse(tablet, err) +} diff --git a/go/vt/vtadmin/sort/clusters.go b/go/vt/vtadmin/sort/clusters.go new file mode 100644 index 00000000000..3d3e78a4f34 --- /dev/null +++ b/go/vt/vtadmin/sort/clusters.go @@ -0,0 +1,44 @@ +/* +Copyright 2020 The Vitess Authors. + +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 sort + +import ( + "sort" + + "vitess.io/vitess/go/vt/vtadmin/cluster" +) + +// ClustersBy provides an interface to sort Clusters by arbitrary comparison. +type ClustersBy func(c1, c2 *cluster.Cluster) bool + +// Sort sorts a slice of Clusters by the given comparison function. +func (by ClustersBy) Sort(clusters []*cluster.Cluster) { + sorter := &clusterSorter{ + clusters: clusters, + by: by, + } + sort.Sort(sorter) +} + +type clusterSorter struct { + clusters []*cluster.Cluster + by func(c1, c2 *cluster.Cluster) bool +} + +func (s clusterSorter) Len() int { return len(s.clusters) } +func (s clusterSorter) Swap(i, j int) { s.clusters[i], s.clusters[j] = s.clusters[j], s.clusters[i] } +func (s clusterSorter) Less(i, j int) bool { return s.by(s.clusters[i], s.clusters[j]) } diff --git a/go/vt/vtadmin/sort/doc.go b/go/vt/vtadmin/sort/doc.go new file mode 100644 index 00000000000..b26a864c1d1 --- /dev/null +++ b/go/vt/vtadmin/sort/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2020 The Vitess Authors. + +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 sort provides convenience wrappers for sorting various vtadmin types. +package sort diff --git a/go/vt/vtadmin/tablets.go b/go/vt/vtadmin/tablets.go new file mode 100644 index 00000000000..739ffac1483 --- /dev/null +++ b/go/vt/vtadmin/tablets.go @@ -0,0 +1,120 @@ +/* +Copyright 2020 The Vitess Authors. + +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 vtadmin + +import ( + "database/sql" + "time" + + "vitess.io/vitess/go/vt/log" + "vitess.io/vitess/go/vt/logutil" + "vitess.io/vitess/go/vt/topo/topoproto" + "vitess.io/vitess/go/vt/vtadmin/cluster" + "vitess.io/vitess/go/vt/vtadmin/vtadminproto" + + topodatapb "vitess.io/vitess/go/vt/proto/topodata" + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +// ParseTablets converts a set of *sql.Rows into a slice of Tablets, for the +// given cluster. +func ParseTablets(rows *sql.Rows, c *cluster.Cluster) ([]*vtadminpb.Tablet, error) { + var tablets []*vtadminpb.Tablet + + for rows.Next() { + if err := rows.Err(); err != nil { + return nil, err + } + + tablet, err := parseTablet(rows, c) + if err != nil { + return nil, err + } + + tablets = append(tablets, tablet) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return tablets, nil +} + +// Fields are: +// Cell | Keyspace | Shard | TabletType (string) | ServingState (string) | Alias | Hostname | MasterTermStartTime. +func parseTablet(rows *sql.Rows, c *cluster.Cluster) (*vtadminpb.Tablet, error) { + var ( + cell string + tabletTypeStr string + servingStateStr string + aliasStr string + mtstStr string + topotablet topodatapb.Tablet + + err error + ) + + if err := rows.Scan( + &cell, + &topotablet.Keyspace, + &topotablet.Shard, + &tabletTypeStr, + &servingStateStr, + &aliasStr, + &topotablet.Hostname, + &mtstStr, + ); err != nil { + return nil, err + } + + tablet := &vtadminpb.Tablet{ + Cluster: &vtadminpb.Cluster{ + Id: c.ID, + Name: c.Name, + }, + Tablet: &topotablet, + } + + topotablet.Type, err = topoproto.ParseTabletType(tabletTypeStr) + if err != nil { + return nil, err + } + + tablet.State = vtadminproto.ParseTabletServingState(servingStateStr) + + topotablet.Alias, err = topoproto.ParseTabletAlias(aliasStr) + if err != nil { + return nil, err + } + + if topotablet.Alias.Cell != cell { + // (TODO:@amason) ??? + log.Warningf("tablet cell %s does not match alias %s. ignoring for now", cell, topoproto.TabletAliasString(topotablet.Alias)) + } + + if mtstStr != "" { + timeTime, err := time.Parse(time.RFC3339, mtstStr) + if err != nil { + return nil, err + } + + topotablet.MasterTermStartTime = logutil.TimeToProto(timeTime) + } + + return tablet, nil +} diff --git a/go/vt/vtadmin/vtadminproto/doc.go b/go/vt/vtadmin/vtadminproto/doc.go new file mode 100644 index 00000000000..28396d3ba67 --- /dev/null +++ b/go/vt/vtadmin/vtadminproto/doc.go @@ -0,0 +1,19 @@ +/* +Copyright 2020 The Vitess Authors. + +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 vtadminproto provides helper functions for working with vtadminpb +// protobuf types. +package vtadminproto diff --git a/go/vt/vtadmin/vtadminproto/tablet.go b/go/vt/vtadmin/vtadminproto/tablet.go new file mode 100644 index 00000000000..a37aa2579b9 --- /dev/null +++ b/go/vt/vtadmin/vtadminproto/tablet.go @@ -0,0 +1,39 @@ +/* +Copyright 2020 The Vitess Authors. + +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 vtadminproto + +import vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + +// ParseTabletServingState returns a ServingState value from the given string. +// If the string does not map to a valid value, this function returns UNKNOWN. +func ParseTabletServingState(state string) vtadminpb.Tablet_ServingState { + if s, ok := vtadminpb.Tablet_ServingState_value[state]; ok { + return vtadminpb.Tablet_ServingState(s) + } + + return vtadminpb.Tablet_UNKNOWN +} + +// TabletServingStateString returns a ServingState represented as a string. If +// the state does not map to a valid value, this function returns "UNKNOWN". +func TabletServingStateString(state vtadminpb.Tablet_ServingState) string { + if s, ok := vtadminpb.Tablet_ServingState_name[int32(state)]; ok { + return s + } + + return "UNKNOWN" +} diff --git a/go/vt/vtadmin/vtsql/config.go b/go/vt/vtadmin/vtsql/config.go new file mode 100644 index 00000000000..e33ae4dd55f --- /dev/null +++ b/go/vt/vtadmin/vtsql/config.go @@ -0,0 +1,133 @@ +/* +Copyright 2020 The Vitess Authors. + +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 vtsql + +import ( + "bytes" + "fmt" + "text/template" + + "github.com/spf13/pflag" + "vitess.io/vitess/go/vt/grpcclient" + "vitess.io/vitess/go/vt/vtadmin/cluster/discovery" +) + +// Config represents the options that modify the behavior of a vtqsl.VTGateProxy. +type Config struct { + Discovery discovery.Discovery + DiscoveryTags []string + Credentials Credentials + + // CredentialsPath is used only to power vtadmin debug endpoints; there may + // be a better way where we don't need to put this in the config, because + // it's not really an "option" in normal use. + CredentialsPath string + + ClusterID string + ClusterName string +} + +// Parse returns a new config with the given cluster ID and name, after +// attempting to parse the command-line pflags into that Config. See +// (*Config).Parse() for more details. +func Parse(clusterID string, clusterName string, disco discovery.Discovery, args []string) (*Config, error) { + cfg := &Config{ + ClusterID: clusterID, + ClusterName: clusterName, + Discovery: disco, + } + + err := cfg.Parse(args) + if err != nil { + return nil, err + } + + return cfg, nil +} + +// Parse reads options specified as command-line pflags (--key=value, note the +// double-dash!) into a vtsql.Config. It is meant to be called from +// (*cluster.Cluster).New(). +func (c *Config) Parse(args []string) error { + fs := pflag.NewFlagSet("", pflag.ContinueOnError) + + fs.StringSliceVar(&c.DiscoveryTags, "discovery-tags", []string{}, + "repeated, comma-separated list of tags to use when discovering a vtgate to connect to. "+ + "the semantics of the tags may depend on the specific discovery implementation used") + + credentialsTmplStr := fs.String("credentials-path-tmpl", "", + "Go template used to specify a path to a credentials file, which is a json file containing "+ + "a Username and Password. Templates are given the context of the vtsql.Config, and primarily "+ + "interoplate the cluster name and ID variables.") + effectiveUser := fs.String("effective-user", "", "username to send queries on behalf of") + + if err := fs.Parse(args); err != nil { + return err + } + + var creds *grpcclient.StaticAuthClientCreds + + if *credentialsTmplStr != "" { + _creds, path, err := c.loadCredentialsFromTemplate(*credentialsTmplStr) + if err != nil { + return fmt.Errorf("cannot load credentials from path template %s: %w", *credentialsTmplStr, err) + } + + c.CredentialsPath = path + creds = _creds + } + + if creds != nil { + // If we did not receive an effective user, but loaded credentials, then the + // immediate user is the effective user. + if *effectiveUser == "" { + *effectiveUser = creds.Username + } + + c.Credentials = &StaticAuthCredentials{ + EffectiveUser: *effectiveUser, + StaticAuthClientCreds: creds, + } + } + + return nil +} + +func (c Config) loadCredentialsFromTemplate(tmplStr string) (*grpcclient.StaticAuthClientCreds, string, error) { + path, err := c.renderTemplate(tmplStr) + if err != nil { + return nil, "", err + } + + creds, err := loadCredentials(path) + + return creds, path, err +} + +func (c Config) renderTemplate(tmplStr string) (string, error) { + tmpl, err := template.New("").Parse(tmplStr) + if err != nil { + return "", err + } + + buf := bytes.NewBuffer(nil) + if err := tmpl.Execute(buf, &c); err != nil { + return "", err + } + + return buf.String(), nil +} diff --git a/go/vt/vtadmin/vtsql/config_test.go b/go/vt/vtadmin/vtsql/config_test.go new file mode 100644 index 00000000000..4bbb1aa09a3 --- /dev/null +++ b/go/vt/vtadmin/vtsql/config_test.go @@ -0,0 +1,142 @@ +/* +Copyright 2020 The Vitess Authors. + +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 vtsql + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "vitess.io/vitess/go/vt/grpcclient" +) + +func TestConfigParse(t *testing.T) { + cfg := Config{} + + // This asserts we do not attempt to load a credentialsFlag via its Set func + // if it's not specified in the args slice. + err := cfg.Parse([]string{}) + assert.NoError(t, err) + + t.Run("", func(t *testing.T) { + f, err := ioutil.TempFile("", "vtsql-config-test-testcluster-*") // testcluster is going to appear in the template + require.NoError(t, err) + + _, err = f.Write([]byte(`{ + "Username": "vtadmin", + "Password": "hunter2" +}`)) + require.NoError(t, err) + + path := f.Name() + defer os.Remove(path) + f.Close() + + dir := filepath.Dir(path) + baseParts := strings.Split(filepath.Base(path), "-") + tmplParts := append(baseParts[:3], "{{ .ClusterName }}", baseParts[4]) + + cfg := &Config{ + ClusterName: "testcluster", + } + + credsTmplStr := filepath.Join(dir, strings.Join(tmplParts, "-")) + + args := []string{ + "--discovery-tags=a:1,b:2", + "--effective-user=vt_appdebug", + "--discovery-tags=c:3", + fmt.Sprintf("--credentials-path-tmpl=%s", credsTmplStr), + } + + expectedCreds := &StaticAuthCredentials{ + EffectiveUser: "vt_appdebug", + StaticAuthClientCreds: &grpcclient.StaticAuthClientCreds{ + Username: "vtadmin", + Password: "hunter2", + }, + } + expectedTags := []string{ + "a:1", + "b:2", + "c:3", + } + + err = cfg.Parse(args) + assert.NoError(t, err) + assert.Equal(t, expectedTags, cfg.DiscoveryTags) + assert.Equal(t, expectedCreds, cfg.Credentials) + }) + + t.Run("", func(t *testing.T) { + f, err := ioutil.TempFile("", "vtsql-config-test-testcluster-*") // testcluster is going to appear in the template + require.NoError(t, err) + + _, err = f.Write([]byte(`{ + "Username": "vtadmin", + "Password": "hunter2" +}`)) + require.NoError(t, err) + + path := f.Name() + defer os.Remove(path) + f.Close() + + dir := filepath.Dir(path) + baseParts := strings.Split(filepath.Base(path), "-") + tmplParts := append(baseParts[:3], "{{ .ClusterName }}", baseParts[4]) + + credsTmplStr := filepath.Join(dir, strings.Join(tmplParts, "-")) + + args := []string{ + "--discovery-tags=a:1,b:2", + "--effective-user=vt_appdebug", + "--discovery-tags=c:3", + fmt.Sprintf("--credentials-path-tmpl=%s", credsTmplStr), + } + + expectedCreds := &StaticAuthCredentials{ + EffectiveUser: "vt_appdebug", + StaticAuthClientCreds: &grpcclient.StaticAuthClientCreds{ + Username: "vtadmin", + Password: "hunter2", + }, + } + expectedTags := []string{ + "a:1", + "b:2", + "c:3", + } + + expected := &Config{ + ClusterID: "cid", + ClusterName: "testcluster", + DiscoveryTags: expectedTags, + Credentials: expectedCreds, + CredentialsPath: path, + } + + cfg, err := Parse("cid", "testcluster", nil, args) + assert.NoError(t, err) + assert.Equal(t, expected, cfg) + }) +} diff --git a/go/vt/vtadmin/vtsql/credentials.go b/go/vt/vtadmin/vtsql/credentials.go new file mode 100644 index 00000000000..87cc990acbe --- /dev/null +++ b/go/vt/vtadmin/vtsql/credentials.go @@ -0,0 +1,69 @@ +/* +Copyright 2020 The Vitess Authors. + +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 vtsql + +import ( + "encoding/json" + "io/ioutil" + + "google.golang.org/grpc/credentials" + "vitess.io/vitess/go/vt/grpcclient" +) + +// Credentials defines the interface needed for vtsql properly connect to and +// query Vitess databases. +type Credentials interface { + // GetEffectiveUsername returns the username on whose behalf the DB is + // issuing queries. + GetEffectiveUsername() string + // GetUsername returns the immediate username for a DB connection. + GetUsername() string + credentials.PerRPCCredentials +} + +// StaticAuthCredentials augments a grpcclient.StaticAuthClientCreds with an +// effective username. +type StaticAuthCredentials struct { + *grpcclient.StaticAuthClientCreds + EffectiveUser string +} + +var _ Credentials = (*StaticAuthCredentials)(nil) + +// GetEffectiveUsername is part of the Credentials interface. +func (creds *StaticAuthCredentials) GetEffectiveUsername() string { + return creds.EffectiveUser +} + +// GetUsername is part of the Credentials interface. +func (creds *StaticAuthCredentials) GetUsername() string { + return creds.Username +} + +func loadCredentials(path string) (*grpcclient.StaticAuthClientCreds, error) { + data, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + + var creds grpcclient.StaticAuthClientCreds + if err := json.Unmarshal(data, &creds); err != nil { + return nil, err + } + + return &creds, nil +} diff --git a/go/vt/vtadmin/vtsql/credentials_test.go b/go/vt/vtadmin/vtsql/credentials_test.go new file mode 100644 index 00000000000..c36f62a1e58 --- /dev/null +++ b/go/vt/vtadmin/vtsql/credentials_test.go @@ -0,0 +1,81 @@ +/* +Copyright 2020 The Vitess Authors. + +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 vtsql + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "vitess.io/vitess/go/vt/grpcclient" +) + +func Test_loadCredentials(t *testing.T) { + tests := []struct { + name string + contents []byte + expected grpcclient.StaticAuthClientCreds + shouldErr bool + }{ + { + name: "success", + contents: []byte(`{ + "Username": "vtadmin", + "Password": "hunter2" +}`), + expected: grpcclient.StaticAuthClientCreds{ + Username: "vtadmin", + Password: "hunter2", + }, + shouldErr: false, + }, + { + name: "not found", + contents: nil, + expected: grpcclient.StaticAuthClientCreds{}, + shouldErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := "" + + if len(tt.contents) > 0 { + f, err := ioutil.TempFile("", "vtsql-credentials-test-*") + require.NoError(t, err) + _, err = f.Write(tt.contents) + require.NoError(t, err) + + path = f.Name() + f.Close() + defer os.Remove(path) + } + + creds, err := loadCredentials(path) + if tt.shouldErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expected, *creds) + }) + } +} diff --git a/go/vt/vtadmin/vtsql/fakevtsql/conn.go b/go/vt/vtadmin/vtsql/fakevtsql/conn.go new file mode 100644 index 00000000000..02a2d07f63e --- /dev/null +++ b/go/vt/vtadmin/vtsql/fakevtsql/conn.go @@ -0,0 +1,101 @@ +/* +Copyright 2020 The Vitess Authors. + +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 fakevtsql + +import ( + "context" + "database/sql/driver" + "errors" + "fmt" + "strings" + + "github.com/stretchr/testify/assert" + "vitess.io/vitess/go/vt/topo/topoproto" + "vitess.io/vitess/go/vt/vtadmin/vtadminproto" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +var ( + // ErrConnClosed is returend when attempting to query a closed connection. + // It is the identical message to vtsql.ErrConnClosed, but redefined to + // prevent an import cycle in package vtsql's tests. + ErrConnClosed = errors.New("use of closed connection") + // ErrUnrecognizedQuery is returned when QueryCnotext is given a query + // string the mock is not set up to handle. + ErrUnrecognizedQuery = errors.New("unrecognized query") +) + +type conn struct { + tablets []*vtadminpb.Tablet + shouldErr bool +} + +var ( + _ driver.Conn = (*conn)(nil) + _ driver.QueryerContext = (*conn)(nil) +) + +func (c *conn) Begin() (driver.Tx, error) { + return nil, nil +} + +func (c *conn) Close() error { + return nil +} + +func (c *conn) Prepare(query string) (driver.Stmt, error) { + return nil, nil +} + +func (c *conn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { + if c.shouldErr { + return nil, assert.AnError + } + + if c == nil { + return nil, ErrConnClosed + } + + switch strings.ToLower(query) { + case "show vitess_tablets", "show tablets": + columns := []string{"Cell", "Keyspace", "Shard", "TabletType", "ServingState", "Alias", "Hostname", "MasterTermStartTime"} + vals := [][]interface{}{} + + for _, tablet := range c.tablets { + vals = append(vals, []interface{}{ + tablet.Tablet.Alias.Cell, + tablet.Tablet.Keyspace, + tablet.Tablet.Shard, + topoproto.TabletTypeLString(tablet.Tablet.Type), + vtadminproto.TabletServingStateString(tablet.State), + topoproto.TabletAliasString(tablet.Tablet.Alias), + tablet.Tablet.Hostname, + "", // (TODO:@amason) use real values here + }) + } + + return &rows{ + cols: columns, + vals: vals, + pos: 0, + closed: false, + }, nil + } + + return nil, fmt.Errorf("%w: %q %v", ErrUnrecognizedQuery, query, args) +} diff --git a/go/vt/vtadmin/vtsql/fakevtsql/doc.go b/go/vt/vtadmin/vtsql/fakevtsql/doc.go new file mode 100644 index 00000000000..34e9b67641b --- /dev/null +++ b/go/vt/vtadmin/vtsql/fakevtsql/doc.go @@ -0,0 +1,49 @@ +/* +Copyright 2020 The Vitess Authors. + +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 fakevtsql provides an interface for mocking out sql.DB responses in +// tests that depend on a vtsql.DB instance. +// +// To use fakevtsql, you will need to create a discovery implementation that +// does not error, e.g. with fakediscovery: +// +// disco := fakediscovery.New() +// disco.AddTaggedGates(nil, []*vtadminpb.VTGate{Hostname: "gate"}) +// +// Then, you will call vtsql.New(), passing the faked discovery implementation +// into the config: +// +// db := vtsql.New("clusterID", &vtsql.Config{ +// Discovery: disco, +// }) +// +// Finally, with your instantiated VTGateProxy instance, you can mock out the +// DialFunc to always return a fakevtsql.Connector. The Tablets and ShouldErr +// attributes of the connector control the behavior: +// +// db.DialFunc = func(cfg vitessdriver.Configuration) (*sql.DB, error) { +// return sql.OpenDB(&fakevtsql.Connector{ +// Tablets: mockTablets, +// ShouldErr: shouldErr, +// }) +// } +// cluster := &cluster.Cluster{ +// /* other attributes */ +// DB: db, +// } +// +// go/vt/vtadmin/api_test.go has several examples of usage. +package fakevtsql diff --git a/go/vt/vtadmin/vtsql/fakevtsql/driver.go b/go/vt/vtadmin/vtsql/fakevtsql/driver.go new file mode 100644 index 00000000000..2829a1dca7c --- /dev/null +++ b/go/vt/vtadmin/vtsql/fakevtsql/driver.go @@ -0,0 +1,56 @@ +/* +Copyright 2020 The Vitess Authors. + +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 fakevtsql + +import ( + "context" + "database/sql/driver" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +type fakedriver struct { + tablets []*vtadminpb.Tablet + shouldErr bool +} + +var _ driver.Driver = (*fakedriver)(nil) + +func (d *fakedriver) Open(name string) (driver.Conn, error) { + return &conn{tablets: d.tablets, shouldErr: d.shouldErr}, nil +} + +// Connector implements the driver.Connector interface, providing a sql-like +// thing that can respond to vtadmin vtsql queries with mocked data. +type Connector struct { + Tablets []*vtadminpb.Tablet + // (TODO:@amason) - allow distinction between Query errors and errors on + // Rows operations (e.g. Next, Err, Scan). + ShouldErr bool +} + +var _ driver.Connector = (*Connector)(nil) + +// Connect is part of the driver.Connector interface. +func (c *Connector) Connect(ctx context.Context) (driver.Conn, error) { + return &conn{tablets: c.Tablets, shouldErr: c.ShouldErr}, nil +} + +// Driver is part of the driver.Connector interface. +func (c *Connector) Driver() driver.Driver { + return &fakedriver{tablets: c.Tablets, shouldErr: c.ShouldErr} +} diff --git a/go/vt/vtadmin/vtsql/fakevtsql/rows.go b/go/vt/vtadmin/vtsql/fakevtsql/rows.go new file mode 100644 index 00000000000..97a74a1ee13 --- /dev/null +++ b/go/vt/vtadmin/vtsql/fakevtsql/rows.go @@ -0,0 +1,75 @@ +/* +Copyright 2020 The Vitess Authors. + +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 fakevtsql + +import ( + "database/sql/driver" + "errors" + "fmt" + "io" +) + +var ( + // ErrBadRow is returned from Next() when a row has an incorrect number of + // fields. + ErrBadRow = errors.New("bad sql row") + // ErrRowsClosed is returned when attempting to operate on an already-closed + // Rows. + ErrRowsClosed = errors.New("err rows closed") +) + +type rows struct { + cols []string + vals [][]interface{} + pos int + + closed bool +} + +var _ driver.Rows = (*rows)(nil) + +func (r *rows) Close() error { + r.closed = true + return nil +} + +func (r *rows) Columns() []string { + return r.cols +} + +func (r *rows) Next(dest []driver.Value) error { + if r.closed { + return ErrRowsClosed + } + + if r.pos >= len(r.vals) { + return io.EOF + } + + row := r.vals[r.pos] + r.pos++ + + if len(row) != len(r.cols) { + return fmt.Errorf("%w: row %d has %d fields but %d cols", ErrBadRow, r.pos-1, len(row), len(r.cols)) + } + + for i := 0; i < len(r.cols); i++ { + dest[i] = row[i] + } + + return nil +} diff --git a/go/vt/vtadmin/vtsql/vtsql.go b/go/vt/vtadmin/vtsql/vtsql.go new file mode 100644 index 00000000000..abd8b7e7784 --- /dev/null +++ b/go/vt/vtadmin/vtsql/vtsql.go @@ -0,0 +1,224 @@ +/* +Copyright 2020 The Vitess Authors. + +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 vtsql + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "google.golang.org/grpc" + "vitess.io/vitess/go/trace" + "vitess.io/vitess/go/vt/callerid" + "vitess.io/vitess/go/vt/vitessdriver" + "vitess.io/vitess/go/vt/vtadmin/cluster/discovery" +) + +// DB defines the connection and query interface of vitess SQL queries used by +// VTAdmin clusters. +type DB interface { + // ShowTablets executes `SHOW vitess_tablets` and returns the result. + ShowTablets(ctx context.Context) (*sql.Rows, error) + + // Dial opens a gRPC database connection to a vtgate in the cluster. If the + // DB already has a valid connection, this is a no-op. + // + // target is a Vitess query target, e.g. "", "", "@replica". + Dial(ctx context.Context, target string, opts ...grpc.DialOption) error + + // Hostname returns the hostname the DB is currently connected to. + Hostname() string + + // Ping behaves like (*sql.DB).Ping. + Ping() error + // PingContext behaves like (*sql.DB).PingContext. + PingContext(ctx context.Context) error + + // Close closes the currently-held database connection. This is a no-op if + // the DB has no current valid connection. It is safe to call repeatedly. + // Users may call Dial on a previously-closed DB to create a new connection, + // but that connection may not be to the same particular vtgate. + Close() error +} + +// VTGateProxy is a proxy for creating and using database connections to vtgates +// in a Vitess cluster. +type VTGateProxy struct { + cluster string + discovery discovery.Discovery + discoveryTags []string + creds Credentials + + // DialFunc is called to open a new database connection. In production this + // should always be vitessdriver.OpenWithConfiguration, but it is exported + // for testing purposes. + DialFunc func(cfg vitessdriver.Configuration) (*sql.DB, error) + + host string + conn *sql.DB +} + +var _ DB = (*VTGateProxy)(nil) + +// ErrConnClosed is returned when attempting to use a closed connection. +var ErrConnClosed = errors.New("use of closed connection") + +// New returns a VTGateProxy to the given cluster. When Dial-ing, it will use +// the given discovery implementation to find a vtgate to connect to, and the +// given creds to dial the underlying gRPC connection, both of which are +// provided by the Config. +// +// It does not open a connection to a vtgate; users must call Dial before first +// use. +func New(cluster string, cfg *Config) *VTGateProxy { + discoveryTags := cfg.DiscoveryTags + if discoveryTags == nil { + discoveryTags = []string{} + } + + return &VTGateProxy{ + cluster: cluster, + discovery: cfg.Discovery, + discoveryTags: discoveryTags, + creds: cfg.Credentials, + DialFunc: vitessdriver.OpenWithConfiguration, + } +} + +// getQueryContext returns a new context with the correct effective and immediate +// Caller IDs set, so queries do not passed to vttablet as the application RW +// user. All calls to to vtgate.conn should pass a context wrapped with this +// function. +// +// It returns the original context unchanged if the vtgate has no credentials +// configured. +func (vtgate *VTGateProxy) getQueryContext(ctx context.Context) context.Context { + if vtgate.creds == nil { + return ctx + } + + return callerid.NewContext( + ctx, + callerid.NewEffectiveCallerID(vtgate.creds.GetEffectiveUsername(), "vtadmin", ""), + callerid.NewImmediateCallerID(vtgate.creds.GetUsername()), + ) +} + +// Dial is part of the DB interface. The proxy's DiscoveryTags can be set to +// narrow the set of possible gates it will connect to. +func (vtgate *VTGateProxy) Dial(ctx context.Context, target string, opts ...grpc.DialOption) error { + span, _ := trace.NewSpan(ctx, "VTGateProxy.Dial") + defer span.Finish() + + vtgate.annotateSpan(span) + + if vtgate.conn != nil { + // (TODO:@amason): consider a quick Ping() check in this case, and get a + // new connection if that fails. + return nil + } + + if vtgate.host == "" { + gate, err := vtgate.discovery.DiscoverVTGateAddr(ctx, vtgate.discoveryTags) + if err != nil { + return err + } + + vtgate.host = gate + // re-annotate the hostname + span.Annotate("vtgate_host", gate) + } + + conf := vitessdriver.Configuration{ + Protocol: fmt.Sprintf("grpc_%s", vtgate.cluster), + Address: vtgate.host, + Target: target, + GRPCDialOptions: opts, + } + + if vtgate.creds != nil { + conf.GRPCDialOptions = append([]grpc.DialOption{ + grpc.WithPerRPCCredentials(vtgate.creds), + grpc.WithInsecure(), + }, conf.GRPCDialOptions...) + } + + db, err := vtgate.DialFunc(conf) + if err != nil { + return err + } + + vtgate.conn = db + + return nil +} + +// ShowTablets is part of the DB interface. +func (vtgate *VTGateProxy) ShowTablets(ctx context.Context) (*sql.Rows, error) { + span, ctx := trace.NewSpan(ctx, "VTGateProxy.ShowTablets") + defer span.Finish() + + vtgate.annotateSpan(span) + + if vtgate.conn == nil { + return nil, ErrConnClosed + } + + return vtgate.conn.QueryContext(vtgate.getQueryContext(ctx), "SHOW vitess_tablets") +} + +// Ping is part of the DB interface. +func (vtgate *VTGateProxy) Ping() error { + return vtgate.PingContext(context.Background()) +} + +// PingContext is part of the DB interface. +func (vtgate *VTGateProxy) PingContext(ctx context.Context) error { + if vtgate.conn == nil { + return ErrConnClosed + } + + return vtgate.conn.PingContext(vtgate.getQueryContext(ctx)) +} + +// Close is part of the DB interface and satisfies io.Closer. +func (vtgate *VTGateProxy) Close() error { + if vtgate.conn == nil { + return nil + } + + err := vtgate.conn.Close() + + vtgate.host = "" + vtgate.conn = nil + + return err +} + +// Hostname is part of the DB interface. +func (vtgate *VTGateProxy) Hostname() string { + return vtgate.host +} + +func (vtgate *VTGateProxy) annotateSpan(span trace.Span) { + span.Annotate("cluster", vtgate.cluster) + + if vtgate.host != "" { + span.Annotate("vtgate_host", vtgate.host) + } +} diff --git a/go/vt/vtadmin/vtsql/vtsql_test.go b/go/vt/vtadmin/vtsql/vtsql_test.go new file mode 100644 index 00000000000..12c7509d1d0 --- /dev/null +++ b/go/vt/vtadmin/vtsql/vtsql_test.go @@ -0,0 +1,160 @@ +/* +Copyright 2020 The Vitess Authors. + +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 vtsql + +import ( + "context" + "database/sql" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "vitess.io/vitess/go/vt/callerid" + "vitess.io/vitess/go/vt/grpcclient" + "vitess.io/vitess/go/vt/vitessdriver" + "vitess.io/vitess/go/vt/vtadmin/cluster/discovery/fakediscovery" + "vitess.io/vitess/go/vt/vtadmin/vtsql/fakevtsql" + + querypb "vitess.io/vitess/go/vt/proto/query" + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtrpcpb "vitess.io/vitess/go/vt/proto/vtrpc" +) + +func assertImmediateCaller(t *testing.T, im *querypb.VTGateCallerID, expected string) { + require.NotNil(t, im, "immediate caller cannot be nil") + assert.Equal(t, im.Username, expected, "immediate caller username mismatch") +} + +func assertEffectiveCaller(t *testing.T, ef *vtrpcpb.CallerID, principal string, component string, subcomponent string) { + require.NotNil(t, ef, "effective caller cannot be nil") + assert.Equal(t, ef.Principal, principal, "effective caller principal mismatch") + assert.Equal(t, ef.Component, component, "effective caller component mismatch") + assert.Equal(t, ef.Subcomponent, subcomponent, "effective caller subcomponent mismatch") +} + +func Test_getQueryContext(t *testing.T) { + ctx := context.Background() + + creds := &StaticAuthCredentials{ + EffectiveUser: "efuser", + StaticAuthClientCreds: &grpcclient.StaticAuthClientCreds{ + Username: "imuser", + }, + } + db := &VTGateProxy{creds: creds} + + outctx := db.getQueryContext(ctx) + assert.NotEqual(t, ctx, outctx, "getQueryContext should return a modified context when credentials are set") + assertEffectiveCaller(t, callerid.EffectiveCallerIDFromContext(outctx), "efuser", "vtadmin", "") + assertImmediateCaller(t, callerid.ImmediateCallerIDFromContext(outctx), "imuser") + + db.creds = nil + outctx = db.getQueryContext(ctx) + assert.Equal(t, ctx, outctx, "getQueryContext should not modify the context when credentials are not set") + + callerctx := callerid.NewContext( + ctx, + callerid.NewEffectiveCallerID("other principal", "vtctld", ""), + callerid.NewImmediateCallerID("other_user"), + ) + db.creds = creds + + outctx = db.getQueryContext(callerctx) + assert.NotEqual(t, callerctx, outctx, "getQueryContext should override an existing callerid in the context") + assertEffectiveCaller(t, callerid.EffectiveCallerIDFromContext(outctx), "efuser", "vtadmin", "") + assertImmediateCaller(t, callerid.ImmediateCallerIDFromContext(outctx), "imuser") +} + +func TestDial(t *testing.T) { + tests := []struct { + name string + disco *fakediscovery.Fake + gates []*vtadminpb.VTGate + proxy *VTGateProxy + dialer func(cfg vitessdriver.Configuration) (*sql.DB, error) + shouldErr bool + }{ + { + name: "existing conn", + proxy: &VTGateProxy{ + conn: sql.OpenDB(&fakevtsql.Connector{}), + }, + shouldErr: false, + }, + { + name: "discovery error", + disco: fakediscovery.New(), + proxy: &VTGateProxy{}, + shouldErr: true, + }, + { + name: "dialer error", + disco: fakediscovery.New(), + gates: []*vtadminpb.VTGate{ + { + Hostname: "gate", + }, + }, + proxy: &VTGateProxy{ + DialFunc: func(cfg vitessdriver.Configuration) (*sql.DB, error) { + return nil, assert.AnError + }, + }, + shouldErr: true, + }, + { + name: "success", + disco: fakediscovery.New(), + gates: []*vtadminpb.VTGate{ + { + Hostname: "gate", + }, + }, + proxy: &VTGateProxy{ + creds: &StaticAuthCredentials{ + StaticAuthClientCreds: &grpcclient.StaticAuthClientCreds{ + Username: "user", + Password: "pass", + }, + }, + DialFunc: func(cfg vitessdriver.Configuration) (*sql.DB, error) { + return sql.OpenDB(&fakevtsql.Connector{}), nil + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.disco != nil { + if len(tt.gates) > 0 { + tt.disco.AddTaggedGates(nil, tt.gates...) + } + + tt.proxy.discovery = tt.disco + } + + err := tt.proxy.Dial(context.Background(), "") + if tt.shouldErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + }) + } +} diff --git a/proto/vtadmin.proto b/proto/vtadmin.proto new file mode 100644 index 00000000000..c1f3fb79c34 --- /dev/null +++ b/proto/vtadmin.proto @@ -0,0 +1,103 @@ +/* +Copyright 2020 The Vitess Authors. + +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. +*/ + +// This package contains the types used by VTAdmin (and later an RPC service). + +syntax = "proto3"; +option go_package = "vitess.io/vitess/go/vt/proto/vtadmin"; + +package vtadmin; + +import "topodata.proto"; + +/* Services */ + +// VTAdmin is the Vitess Admin API service. It provides RPCs that operate on +// across a range of Vitess clusters. +service VTAdmin { + // GetGates returns all gates across all the specified clusters. + rpc GetGates(GetGatesRequest) returns (GetGatesResponse) {}; + // GetTablet looks up a tablet by hostname across all clusters and returns + // the result. + rpc GetTablet(GetTabletRequest) returns (Tablet) {}; + // GetTablets returns all tablets across all the specified clusters. + rpc GetTablets(GetTabletsRequest) returns (GetTabletsResponse) {}; +} + +/* Data types */ + +// Cluster represents information about a Vitess cluster. +message Cluster { + string id = 1; + string name = 2; +} + +// Tablet groups the topo information of a tablet together with the Vitess +// cluster it belongs to. +message Tablet { + Cluster cluster = 1; + topodata.Tablet tablet = 2; + + enum ServingState { + UNKNOWN = 0; + SERVING = 1; + NOT_SERVING = 2; + } + + ServingState state = 3; +} + +// VTGate represents information about a single VTGate host. +message VTGate { + // Hostname is the shortname of the VTGate. + string hostname = 1; + // Pool is group the VTGate serves queries for. Some deployments segment + // VTGates into groups or pools, based on the workloads they serve queries + // for. Use of this field is optional. + string pool = 2; + // Cell is the topology cell the VTGate is in. + string cell = 3; + // Cluster is the name of the cluster the VTGate serves. + string cluster = 4; + // Keyspaces is the list of keyspaces-to-watch for the VTGate. + repeated string keyspaces = 5; +} + +/* Request/Response types */ + +message GetGatesRequest { + repeated string cluster_ids = 1; +} + +message GetGatesResponse { + repeated VTGate gates = 1; +} + +message GetTabletRequest { + string hostname = 1; + // ClusterIDs is an optional parameter to narrow the scope of the search, if + // the caller knows which cluster the tablet may be in, or, to disamiguate if + // multiple clusters have a tablet with the same hostname. + repeated string cluster_ids = 2; +} + +message GetTabletsRequest { + repeated string cluster_ids = 1; +} + +message GetTabletsResponse { + repeated Tablet tablets = 1; +}