From e784314aa29df15b6371768c9c669606f2afbbab Mon Sep 17 00:00:00 2001 From: Mark Mandel Date: Sat, 25 Aug 2018 14:08:55 -0700 Subject: [PATCH] Ability to pass `GameServer` yaml/json to local sdk server To be able to work locally, you need to be able to specify your local `GameServer` configuration, as it likely will have application specific configuration in it -- or maybe you want to specify what state it's in. This commit allow you to specify the local resource as either yaml/json through a `-f` or `--file` flag. Closes #296 --- cmd/sdk-server/main.go | 58 +++++++++++++++++++-- pkg/gameservers/localsdk.go | 19 +++++-- pkg/gameservers/localsdk_test.go | 22 ++++++-- pkg/gameservers/sdk.go | 65 +++++++++++++++++++++++ pkg/gameservers/sdk_test.go | 87 +++++++++++++++++++++++++++++++ pkg/gameservers/sdkserver.go | 49 +---------------- pkg/gameservers/sdkserver_test.go | 67 ------------------------ sdks/README.md | 24 ++++++++- 8 files changed, 261 insertions(+), 130 deletions(-) create mode 100644 pkg/gameservers/sdk.go create mode 100644 pkg/gameservers/sdk_test.go diff --git a/cmd/sdk-server/main.go b/cmd/sdk-server/main.go index ea522fa744..f7a47fd1eb 100644 --- a/cmd/sdk-server/main.go +++ b/cmd/sdk-server/main.go @@ -19,19 +19,24 @@ import ( "fmt" "net" "net/http" + "os" + "path/filepath" "strings" "agones.dev/agones/pkg" + "agones.dev/agones/pkg/apis/stable/v1alpha1" "agones.dev/agones/pkg/client/clientset/versioned" "agones.dev/agones/pkg/gameservers" "agones.dev/agones/pkg/sdk" "agones.dev/agones/pkg/util/runtime" "agones.dev/agones/pkg/util/signals" gwruntime "github.com/grpc-ecosystem/grpc-gateway/runtime" + "github.com/pkg/errors" "github.com/spf13/pflag" "github.com/spf13/viper" "golang.org/x/net/context" "google.golang.org/grpc" + "k8s.io/apimachinery/pkg/util/yaml" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" ) @@ -46,6 +51,7 @@ const ( // Flags (that can also be env vars) localFlag = "local" + fileFlag = "file" addressFlag = "address" ) @@ -81,7 +87,10 @@ func main() { defer cancel() if ctlConf.IsLocal { - sdk.RegisterSDKServer(grpcServer, gameservers.NewLocalSDKServer()) + err = registerLocal(grpcServer, ctlConf) + if err != nil { + logger.WithError(err).Fatal("Could not start local sdk server") + } } else { var config *rest.Config config, err = rest.InClusterConfig() @@ -124,6 +133,41 @@ func main() { logger.Info("shutting down sdk server") } +func registerLocal(grpcServer *grpc.Server, ctlConf config) error { + var local *gameservers.LocalSDKServer + if ctlConf.LocalFile != "" { + path, err := filepath.Abs(ctlConf.LocalFile) + if err != nil { + return err + } + + if _, err = os.Stat(path); os.IsNotExist(err) { + return errors.Errorf("Could not find file: %s", path) + } + + logger.WithField("path", path).Info("Reading GameServer configuration") + reader, err := os.Open(path) // nolint: gosec + if err != nil { + return err + } + + var gs v1alpha1.GameServer + // 4096 is what Kubernetes uses + decoder := yaml.NewYAMLOrJSONDecoder(reader, 4096) + err = decoder.Decode(&gs) + if err != nil { + return err + } + local = gameservers.NewLocalSDKServer(&gs) + } else { + local = gameservers.NewLocalSDKServer(nil) + } + + sdk.RegisterSDKServer(grpcServer, local) + + return nil +} + // runGrpc runs the grpc service func runGrpc(grpcServer *grpc.Server, lis net.Listener) { logger.Info("Starting SDKServer grpc service...") @@ -157,9 +201,11 @@ func runGateway(ctx context.Context, grpcEndpoint string, mux *gwruntime.ServeMu // a configuration structure func parseEnvFlags() config { viper.SetDefault(localFlag, false) + viper.SetDefault(fileFlag, "") viper.SetDefault(addressFlag, "localhost") pflag.Bool(localFlag, viper.GetBool(localFlag), "Set this, or LOCAL env, to 'true' to run this binary in local development mode. Defaults to 'false'") + pflag.StringP(fileFlag, "f", viper.GetString(fileFlag), "Set this, or FILE env var to the path of a local yaml or json file that contains your GameServer resoure configuration") pflag.String(addressFlag, viper.GetString(addressFlag), "The Address to bind the server grpcPort to. Defaults to 'localhost") pflag.Parse() @@ -170,13 +216,15 @@ func parseEnvFlags() config { runtime.Must(viper.BindPFlags(pflag.CommandLine)) return config{ - IsLocal: viper.GetBool(localFlag), - Address: viper.GetString(addressFlag), + IsLocal: viper.GetBool(localFlag), + Address: viper.GetString(addressFlag), + LocalFile: viper.GetString(fileFlag), } } // config is all the configuration for this program type config struct { - Address string - IsLocal bool + Address string + IsLocal bool + LocalFile string } diff --git a/pkg/gameservers/localsdk.go b/pkg/gameservers/localsdk.go index c76f29cae2..83ded92d11 100644 --- a/pkg/gameservers/localsdk.go +++ b/pkg/gameservers/localsdk.go @@ -18,6 +18,7 @@ import ( "io" "time" + "agones.dev/agones/pkg/apis/stable/v1alpha1" "agones.dev/agones/pkg/sdk" "github.com/pkg/errors" "github.com/sirupsen/logrus" @@ -27,7 +28,7 @@ import ( var ( _ sdk.SDKServer = &LocalSDKServer{} - fixture = &sdk.GameServer{ + defaultGs = &sdk.GameServer{ ObjectMeta: &sdk.GameServer_ObjectMeta{ Name: "local", Namespace: "default", @@ -50,14 +51,22 @@ var ( // is being run for local development, and doesn't connect to the // Kubernetes cluster type LocalSDKServer struct { + gs *sdk.GameServer watchPeriod time.Duration } // NewLocalSDKServer returns the default LocalSDKServer -func NewLocalSDKServer() *LocalSDKServer { - return &LocalSDKServer{ +func NewLocalSDKServer(gs *v1alpha1.GameServer) *LocalSDKServer { + lss := &LocalSDKServer{ watchPeriod: 5 * time.Second, + gs: defaultGs, } + + if gs != nil { + lss.gs = convert(gs) + } + + return lss } // Ready logs that the Ready request has been received @@ -90,7 +99,7 @@ func (l *LocalSDKServer) Health(stream sdk.SDK_HealthServer) error { // GetGameServer returns a dummy game server. func (l *LocalSDKServer) GetGameServer(context.Context, *sdk.Empty) (*sdk.GameServer, error) { logrus.Info("getting GameServer details") - return fixture, nil + return l.gs, nil } // WatchGameServer will return a dummy GameServer (with no changes), 3 times, every 5 seconds @@ -100,7 +109,7 @@ func (l *LocalSDKServer) WatchGameServer(_ *sdk.Empty, stream sdk.SDK_WatchGameS for i := 0; i < times; i++ { logrus.Info("Sending watched GameServer!") - err := stream.Send(fixture) + err := stream.Send(l.gs) if err != nil { logrus.WithError(err).Error("error sending gameserver") return err diff --git a/pkg/gameservers/localsdk_test.go b/pkg/gameservers/localsdk_test.go index e355f7677f..e035f86be0 100644 --- a/pkg/gameservers/localsdk_test.go +++ b/pkg/gameservers/localsdk_test.go @@ -19,15 +19,17 @@ import ( "testing" "time" + "agones.dev/agones/pkg/apis/stable/v1alpha1" "agones.dev/agones/pkg/sdk" "github.com/stretchr/testify/assert" "golang.org/x/net/context" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestLocal(t *testing.T) { ctx := context.Background() e := &sdk.Empty{} - l := NewLocalSDKServer() + l := NewLocalSDKServer(nil) _, err := l.Ready(ctx, e) assert.Nil(t, err, "Ready should not error") @@ -53,14 +55,26 @@ func TestLocal(t *testing.T) { gs, err := l.GetGameServer(ctx, e) assert.Nil(t, err) - assert.Equal(t, fixture, gs) + assert.Equal(t, defaultGs, gs) +} + +func TestLocalSDKWithGameServer(t *testing.T) { + ctx := context.Background() + e := &sdk.Empty{} + + fixture := &v1alpha1.GameServer{ObjectMeta: metav1.ObjectMeta{Name: "stuff"}} + l := NewLocalSDKServer(fixture.DeepCopy()) + gs, err := l.GetGameServer(ctx, e) + assert.Nil(t, err) + + assert.Equal(t, fixture.ObjectMeta.Name, gs.ObjectMeta.Name) } func TestLocalSDKServerWatchGameServer(t *testing.T) { t.Parallel() e := &sdk.Empty{} - l := NewLocalSDKServer() + l := NewLocalSDKServer(nil) l.watchPeriod = time.Second stream := newGameServerMockStream() @@ -70,7 +84,7 @@ func TestLocalSDKServerWatchGameServer(t *testing.T) { for i := 0; i < 3; i++ { select { case msg := <-stream.msgs: - assert.Equal(t, fixture, msg) + assert.Equal(t, defaultGs, msg) case <-time.After(2 * l.watchPeriod): assert.FailNow(t, "timeout on receiving messagess") } diff --git a/pkg/gameservers/sdk.go b/pkg/gameservers/sdk.go new file mode 100644 index 0000000000..3fad6d4546 --- /dev/null +++ b/pkg/gameservers/sdk.go @@ -0,0 +1,65 @@ +// Copyright 2018 Google Inc. All Rights Reserved. +// +// 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 gameservers + +import ( + "agones.dev/agones/pkg/apis/stable/v1alpha1" + "agones.dev/agones/pkg/sdk" +) + +// convert converts a K8s GameServer object, into a gRPC SDK GameServer object +func convert(gs *v1alpha1.GameServer) *sdk.GameServer { + meta := gs.ObjectMeta + status := gs.Status + health := gs.Spec.Health + result := &sdk.GameServer{ + ObjectMeta: &sdk.GameServer_ObjectMeta{ + Name: meta.Name, + Namespace: meta.Namespace, + Uid: string(meta.UID), + ResourceVersion: meta.ResourceVersion, + Generation: meta.Generation, + CreationTimestamp: meta.CreationTimestamp.Unix(), + Annotations: meta.Annotations, + Labels: meta.Labels, + }, + Spec: &sdk.GameServer_Spec{ + Health: &sdk.GameServer_Spec_Health{ + Disabled: health.Disabled, + PeriodSeconds: health.PeriodSeconds, + FailureThreshold: health.FailureThreshold, + InitialDelaySeconds: health.InitialDelaySeconds, + }, + }, + Status: &sdk.GameServer_Status{ + State: string(status.State), + Address: status.Address, + }, + } + if meta.DeletionTimestamp != nil { + result.ObjectMeta.DeletionTimestamp = meta.DeletionTimestamp.Unix() + } + + // loop around and add all the ports + for _, p := range status.Ports { + grpcPort := &sdk.GameServer_Status_Port{ + Name: p.Name, + Port: p.Port, + } + result.Status.Ports = append(result.Status.Ports, grpcPort) + } + + return result +} diff --git a/pkg/gameservers/sdk_test.go b/pkg/gameservers/sdk_test.go new file mode 100644 index 0000000000..badb3bde4f --- /dev/null +++ b/pkg/gameservers/sdk_test.go @@ -0,0 +1,87 @@ +// Copyright 2018 Google Inc. All Rights Reserved. +// +// 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 gameservers + +import ( + "testing" + + "agones.dev/agones/pkg/apis/stable/v1alpha1" + "agones.dev/agones/pkg/sdk" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestConvert(t *testing.T) { + t.Parallel() + + fixture := &v1alpha1.GameServer{ + ObjectMeta: v1.ObjectMeta{ + CreationTimestamp: v1.Now(), + Namespace: "default", + Name: "test", + Labels: map[string]string{"foo": "bar"}, + Annotations: map[string]string{"stuff": "things"}, + UID: "1234", + }, + Spec: v1alpha1.GameServerSpec{ + Health: v1alpha1.Health{ + Disabled: false, + InitialDelaySeconds: 10, + FailureThreshold: 15, + PeriodSeconds: 20, + }, + }, + Status: v1alpha1.GameServerStatus{ + NodeName: "george", + Address: "127.0.0.1", + State: "Ready", + Ports: []v1alpha1.GameServerStatusPort{ + {Name: "default", Port: 12345}, + {Name: "beacon", Port: 123123}, + }, + }, + } + + eq := func(t *testing.T, fixture *v1alpha1.GameServer, sdkGs *sdk.GameServer) { + assert.Equal(t, fixture.ObjectMeta.Name, sdkGs.ObjectMeta.Name) + assert.Equal(t, fixture.ObjectMeta.Namespace, sdkGs.ObjectMeta.Namespace) + assert.Equal(t, fixture.ObjectMeta.CreationTimestamp.Unix(), sdkGs.ObjectMeta.CreationTimestamp) + assert.Equal(t, string(fixture.ObjectMeta.UID), sdkGs.ObjectMeta.Uid) + assert.Equal(t, fixture.ObjectMeta.Labels, sdkGs.ObjectMeta.Labels) + assert.Equal(t, fixture.ObjectMeta.Annotations, sdkGs.ObjectMeta.Annotations) + assert.Equal(t, fixture.Spec.Health.Disabled, sdkGs.Spec.Health.Disabled) + assert.Equal(t, fixture.Spec.Health.InitialDelaySeconds, sdkGs.Spec.Health.InitialDelaySeconds) + assert.Equal(t, fixture.Spec.Health.FailureThreshold, sdkGs.Spec.Health.FailureThreshold) + assert.Equal(t, fixture.Spec.Health.PeriodSeconds, sdkGs.Spec.Health.PeriodSeconds) + assert.Equal(t, fixture.Status.Address, sdkGs.Status.Address) + assert.Equal(t, string(fixture.Status.State), sdkGs.Status.State) + assert.Len(t, sdkGs.Status.Ports, len(fixture.Status.Ports)) + for i, fp := range fixture.Status.Ports { + p := sdkGs.Status.Ports[i] + assert.Equal(t, fp.Name, p.Name) + assert.Equal(t, fp.Port, p.Port) + } + } + + sdkGs := convert(fixture) + eq(t, fixture, sdkGs) + assert.Zero(t, sdkGs.ObjectMeta.DeletionTimestamp) + + now := v1.Now() + fixture.DeletionTimestamp = &now + sdkGs = convert(fixture) + eq(t, fixture, sdkGs) + assert.Equal(t, fixture.ObjectMeta.DeletionTimestamp.Unix(), sdkGs.ObjectMeta.DeletionTimestamp) +} diff --git a/pkg/gameservers/sdkserver.go b/pkg/gameservers/sdkserver.go index 387def3c7f..6799a5f381 100644 --- a/pkg/gameservers/sdkserver.go +++ b/pkg/gameservers/sdkserver.go @@ -263,7 +263,7 @@ func (s *SDKServer) GetGameServer(context.Context, *sdk.Empty) (*sdk.GameServer, return nil, errors.Wrapf(err, "error retrieving gameserver %s/%s", s.namespace, s.gameServerName) } - return s.convert(gs), nil + return convert(gs), nil } // WatchGameServer sends events through the stream when changes occur to the @@ -286,7 +286,7 @@ func (s *SDKServer) sendGameServerUpdate(gs *stablev1alpha1.GameServer) { defer s.streamMutex.RUnlock() for _, stream := range s.connectedStreams { - err := stream.Send(s.convert(gs)) + err := stream.Send(convert(gs)) // We essentially ignoring any disconnected streams. // I think this is fine, as disconnections shouldn't actually happen. // but we should log them, just in case they do happen, and we can track it @@ -297,51 +297,6 @@ func (s *SDKServer) sendGameServerUpdate(gs *stablev1alpha1.GameServer) { } } -// convert converts a K8s GameServer object, into a gRPC SDK GameServer object -func (s *SDKServer) convert(gs *stablev1alpha1.GameServer) *sdk.GameServer { - meta := gs.ObjectMeta - status := gs.Status - health := gs.Spec.Health - result := &sdk.GameServer{ - ObjectMeta: &sdk.GameServer_ObjectMeta{ - Name: meta.Name, - Namespace: meta.Namespace, - Uid: string(meta.UID), - ResourceVersion: meta.ResourceVersion, - Generation: meta.Generation, - CreationTimestamp: meta.CreationTimestamp.Unix(), - Annotations: meta.Annotations, - Labels: meta.Labels, - }, - Spec: &sdk.GameServer_Spec{ - Health: &sdk.GameServer_Spec_Health{ - Disabled: health.Disabled, - PeriodSeconds: health.PeriodSeconds, - FailureThreshold: health.FailureThreshold, - InitialDelaySeconds: health.InitialDelaySeconds, - }, - }, - Status: &sdk.GameServer_Status{ - State: string(status.State), - Address: status.Address, - }, - } - if meta.DeletionTimestamp != nil { - result.ObjectMeta.DeletionTimestamp = meta.DeletionTimestamp.Unix() - } - - // loop around and add all the ports - for _, p := range status.Ports { - grpcPort := &sdk.GameServer_Status_Port{ - Name: p.Name, - Port: p.Port, - } - result.Status.Ports = append(result.Status.Ports, grpcPort) - } - - return result -} - // runHealth actively checks the health, and if not // healthy will push the Unhealthy state into the queue so // it can be updated diff --git a/pkg/gameservers/sdkserver_test.go b/pkg/gameservers/sdkserver_test.go index 996098854d..2c3ffa35aa 100644 --- a/pkg/gameservers/sdkserver_test.go +++ b/pkg/gameservers/sdkserver_test.go @@ -359,73 +359,6 @@ func TestSidecarHTTPHealthCheck(t *testing.T) { wg.Wait() // wait for go routine test results. } -func TestSDKServerConvert(t *testing.T) { - t.Parallel() - - fixture := &v1alpha1.GameServer{ - ObjectMeta: metav1.ObjectMeta{ - CreationTimestamp: metav1.Now(), - Namespace: "default", - Name: "test", - Labels: map[string]string{"foo": "bar"}, - Annotations: map[string]string{"stuff": "things"}, - UID: "1234", - }, - Spec: v1alpha1.GameServerSpec{ - Health: v1alpha1.Health{ - Disabled: false, - InitialDelaySeconds: 10, - FailureThreshold: 15, - PeriodSeconds: 20, - }, - }, - Status: v1alpha1.GameServerStatus{ - NodeName: "george", - Address: "127.0.0.1", - State: "Ready", - Ports: []v1alpha1.GameServerStatusPort{ - {Name: "default", Port: 12345}, - {Name: "beacon", Port: 123123}, - }, - }, - } - - m := agtesting.NewMocks() - sc, err := defaultSidecar(m) - assert.Nil(t, err) - - eq := func(t *testing.T, fixture *v1alpha1.GameServer, sdkGs *sdk.GameServer) { - assert.Equal(t, fixture.ObjectMeta.Name, sdkGs.ObjectMeta.Name) - assert.Equal(t, fixture.ObjectMeta.Namespace, sdkGs.ObjectMeta.Namespace) - assert.Equal(t, fixture.ObjectMeta.CreationTimestamp.Unix(), sdkGs.ObjectMeta.CreationTimestamp) - assert.Equal(t, string(fixture.ObjectMeta.UID), sdkGs.ObjectMeta.Uid) - assert.Equal(t, fixture.ObjectMeta.Labels, sdkGs.ObjectMeta.Labels) - assert.Equal(t, fixture.ObjectMeta.Annotations, sdkGs.ObjectMeta.Annotations) - assert.Equal(t, fixture.Spec.Health.Disabled, sdkGs.Spec.Health.Disabled) - assert.Equal(t, fixture.Spec.Health.InitialDelaySeconds, sdkGs.Spec.Health.InitialDelaySeconds) - assert.Equal(t, fixture.Spec.Health.FailureThreshold, sdkGs.Spec.Health.FailureThreshold) - assert.Equal(t, fixture.Spec.Health.PeriodSeconds, sdkGs.Spec.Health.PeriodSeconds) - assert.Equal(t, fixture.Status.Address, sdkGs.Status.Address) - assert.Equal(t, string(fixture.Status.State), sdkGs.Status.State) - assert.Len(t, sdkGs.Status.Ports, len(fixture.Status.Ports)) - for i, fp := range fixture.Status.Ports { - p := sdkGs.Status.Ports[i] - assert.Equal(t, fp.Name, p.Name) - assert.Equal(t, fp.Port, p.Port) - } - } - - sdkGs := sc.convert(fixture) - eq(t, fixture, sdkGs) - assert.Zero(t, sdkGs.ObjectMeta.DeletionTimestamp) - - now := metav1.Now() - fixture.DeletionTimestamp = &now - sdkGs = sc.convert(fixture) - eq(t, fixture, sdkGs) - assert.Equal(t, fixture.ObjectMeta.DeletionTimestamp.Unix(), sdkGs.ObjectMeta.DeletionTimestamp) -} - func TestSDKServerGetGameServer(t *testing.T) { t.Parallel() diff --git a/sdks/README.md b/sdks/README.md index 1dd10f1497..f2315ca561 100644 --- a/sdks/README.md +++ b/sdks/README.md @@ -108,13 +108,33 @@ To run in local mode, pass the flag `--local` to the executable. For example: -```bash +```console $ ./sidecar.linux.amd64 --local -{"level":"info","local":true,"msg":"Starting sdk sidecar","port":59357,"time":"2017-12-22T16:09:03-08:00","version":"0.1-5217b21"} +{"ctlConf":{"Address":"localhost","IsLocal":true,"LocalFile":""},"grpcPort":59357,"httpPort":59358,"level":"info","msg":"Starting sdk sidecar","source":"main","time":"2018-08-25T18:01:58-07:00","version":"0.4.0-b44960a8"} +{"level":"info","msg":"Starting SDKServer grpc service...","source":"main","time":"2018-08-25T18:01:58-07:00"} +{"level":"info","msg":"Starting SDKServer grpc-gateway...","source":"main","time":"2018-08-25T18:01:58-07:00"} {"level":"info","msg":"Ready request has been received!","time":"2017-12-22T16:09:19-08:00"} {"level":"info","msg":"Shutdown request has been received!","time":"2017-12-22T16:10:19-08:00"} ``` +### Providing your own `GameServer` configuration for local development + +⚠️⚠️⚠️ **Providing your own `GameServer` is currently a development feature and has not been released** ⚠️⚠️⚠️ + +By default, the local sdk-server will create a dummy `GameServer` configuration that is used for `GameServer()` +and `WatchGameServer()` SDK calls. If you wish to provide your own configuration, as either yaml or json, this +can be passed through as either `--file` or `-f` along with the `--local` flag. + +For example: + +```console +$ ./sdk-server.linux.amd64 --local -f ../../../examples/simple-udp/gameserver.yaml +{"ctlConf":{"Address":"localhost","IsLocal":true,"LocalFile":"../../../examples/simple-udp/gameserver.yaml"},"grpcPort":59357,"httpPort":59358,"level":"info","msg":"Starting sdk sidecar","source":"main","time":"2018-08-25T17:56:39-07:00","version":"0.4.0-b44960a8"} +{"level":"info","msg":"Reading GameServer configuration","path":"/home/user/workspace/agones/src/agones.dev/agones/examples/simple-udp/gameserver.yaml","source":"main","time":"2018-08-25T17:56:39-07:00"} +{"level":"info","msg":"Starting SDKServer grpc service...","source":"main","time":"2018-08-25T17:56:39-07:00"} +{"level":"info","msg":"Starting SDKServer grpc-gateway...","source":"main","time":"2018-08-25T17:56:39-07:00"} +``` + ### Writing your own SDK If there isn't a SDK for the language and platform you are looking for, you have several options: